From 1b47cc5d7a6659e67dca48c1cfffeb2e90a719c1 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 19:15:19 +0500 Subject: [PATCH] =?UTF-8?q?feat(tradein/avito):=20=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=D0=B5=D1=82=20=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D1=87?= =?UTF-8?q?=D0=BA=D1=83=20=D1=80=D0=BE=D0=B4=D0=BD=D1=8B=D0=BC=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=BE=D0=BC=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D0=B8=20(#3033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: fetch_all_secondary(secondary_only=True) строил URL ОБЩЕЙ выдачи (/kvartiry/prodam/), скачивал 100 % (46 573 объявления в ЕКБ) и выбрасывал новостройки после разбора — 79,4 % страниц и антибан-бюджета впустую. У Авито есть родной фильтр: читаемый путь /kvartiry/prodam/1-komnatnye/vtorichka (редирект на канонический, 2 551 объявление) — проверено вживую 21.08.2026 (#3033). Чем полнее обход, тем выше отказ (full_load 83 % banned/failed против newbuilding_sweep 9,5 %) — объём сам по себе фактор бана. Что: _build_rooms_url в secondary-режиме (флаг на инстансе на время fetch_all_secondary, сбрасывается в finally) строит путь из читаемого префикса room-slug + /vtorichka; query (s, p, pmin, pmax) без изменений — ценовая бисекция работает как раньше, но уже по распределению вторички. Post-parse фильтр остаётся страховкой и счётчиком: last_dropped_nb → AvitoFullLoadCounters.dropped_novostroyki (зеркало cian #1781) — при работающем пути ≈0, рост = Авито перестал уважать путь, видно по числу. Тест: подмена _fetch_serp_html, probe-URL всех seed-брекетов; на main красный по значению (ASgB-slug в пути, /vtorichka нет); контроли — secondary_only=False не меняется, режим не течёт за пределы вызова. Refs #3033 --- ...test_3033_avito_native_secondary_filter.py | 97 +++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 9 +- .../src/scraper_kit/providers/avito/serp.py | 66 ++++++++++++- 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py diff --git a/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py b/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py new file mode 100644 index 00000000..7e4c3dae --- /dev/null +++ b/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py @@ -0,0 +1,97 @@ +"""Полный обход Авито качает ВТОРИЧКУ родным фильтром пути, а не всё подряд (#3033). + +До правки `fetch_all_secondary(secondary_only=True)` строил URL общей выдачи +(`/kvartiry/prodam/`), скачивал 100 % (46 573 объявления в ЕКБ) и выбрасывал +новостройки после разбора — 79,4 % страниц и антибан-бюджета в корзину. Родной фильтр +вторички есть: `/kvartiry/prodam/1-komnatnye/vtorichka` (читаемые сегменты, Авито +редиректит на канонический URL; 2 551 объявление) — проверено вживую 21.08.2026. + +Сеть не нужна: `_fetch_serp_html` подменяется и собирает URL; страница отдаёт +`page-title/count`=0, поэтому бакет скипается сразу после probe, а probe-URL — ровно +тот, по которому пошёл бы обход. На origin/main первый и пятый тесты красные ПО ЗНАЧЕНИЮ +(в пути ASgB-slug комнатности и нет `/vtorichka`). +""" + +from __future__ import annotations + +import asyncio +import os +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from scraper_kit.providers.avito.serp import ROOM_SLUGS, AvitoScraper + +_ONE_ROOM = ROOM_SLUGS[1] # ("1-комн.", "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ") +_EMPTY_SERP = '0' + + +def _cfg() -> SimpleNamespace: + # __init__ читает только то, что нужно без __aenter__: fetch_mode/ekb_only. + return SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http") + + +def _collect_urls(secondary_only: bool) -> tuple[AvitoScraper, list[str]]: + s = AvitoScraper(_cfg()) # type: ignore[arg-type] + urls: list[str] = [] + + async def fake_fetch(url: str, page: int) -> str: + urls.append(url) + return _EMPTY_SERP + + s._fetch_serp_html = fake_fetch # type: ignore[method-assign] + asyncio.run(s.fetch_all_secondary(secondary_only=secondary_only, rooms_buckets=[_ONE_ROOM])) + return s, urls + + +def test_secondary_only_requests_native_vtorichka_path() -> None: + """Головной: каждый probe-URL обхода вторички — читаемый путь комнатности + /vtorichka.""" + _s, urls = _collect_urls(secondary_only=True) + assert urls, "обход не сделал ни одного запроса — подмена fetch не сработала" + paths = {urlparse(u).path for u in urls} + assert paths == {"/ekaterinburg/kvartiry/prodam/1-komnatnye/vtorichka"}, paths + # ценовая бисекция/сортировка живут в query и не теряются + q = parse_qs(urlparse(urls[0]).query) + assert q["s"] == ["104"] and q["p"] == ["1"] + assert any("pmin" in parse_qs(urlparse(u).query) for u in urls), "pmin пропал из URL" + + +def test_non_secondary_keeps_legacy_room_slug_url() -> None: + """Контроль: secondary_only=False — прежний URL (ASgB-slug комнатности), без /vtorichka.""" + _s, urls = _collect_urls(secondary_only=False) + assert urls + for u in urls: + p = urlparse(u).path + assert p == f"/ekaterinburg/kvartiry/prodam/{_ONE_ROOM[1]}", p + assert "/vtorichka" not in p + + +def test_mode_does_not_leak_after_run() -> None: + """Режим живёт только внутри fetch_all_secondary: после него билдер строит прежний URL.""" + s, _urls = _collect_urls(secondary_only=True) + assert "/vtorichka" not in s._build_rooms_url(_ONE_ROOM[1], 1) + + +def test_dropped_counter_reset_and_exposed() -> None: + """Post-parse фильтр остаётся страховкой: счётчик отброшенных обнуляется и читается.""" + s, _urls = _collect_urls(secondary_only=True) + assert getattr(s, "last_dropped_nb", None) == 0 + + +def test_every_room_slug_gets_readable_prefix() -> None: + """Все 7 комнатностей: путь = читаемый префикс slug'а + /vtorichka, без хвоста ASgB.""" + s = AvitoScraper(_cfg()) # type: ignore[arg-type] + s._secondary_path = True + for _name, slug in ROOM_SLUGS: + readable = slug.split("-ASgB", 1)[0] + path = urlparse(s._build_rooms_url(slug, 3, 1_000_000, 5_000_000)).path + assert path == f"/ekaterinburg/kvartiry/prodam/{readable}/vtorichka", path + assert "ASgB" not in path + + +def test_pipeline_counters_carry_dropped_novostroyki() -> None: + """Пайплайн сохраняет счётчик в scrape_runs.counters (зеркало cian #1781).""" + from scraper_kit.orchestration.pipeline import AvitoFullLoadCounters + + assert "dropped_novostroyki" in AvitoFullLoadCounters().to_dict() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 31647a6b..5b25da85 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -3761,6 +3761,10 @@ class AvitoFullLoadCounters: errors_count: int = 0 # Бакеты, отданные SERP-слоем как НЕполные — см. CianFullLoadCounters. partial_buckets: int = 0 + # #3033: новостройки, отброшенные post-parse фильтром secondary_only. При родном + # фильтре вторички в URL ожидается ≈0; рост = путь перестал фильтровать (редирект + # на общую выдачу) — видно по счётчику, а не по тишине. Зеркало cian (#1781). + dropped_novostroyki: int = 0 def to_dict(self) -> dict[str, int]: return {f.name: getattr(self, f.name) for f in fields(self)} @@ -3918,13 +3922,16 @@ async def run_avito_full_load( skip_buckets=skip_set if skip_set else None, since=since, ) + counters.dropped_novostroyki = getattr(scraper, "last_dropped_nb", 0) logger.info( - "avito-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d", + "avito-full-load run_id=%d: fetch done — unique=%d ins=%d upd=%d " + "dropped_novostroyki=%d", run_id, counters.unique_fetched, counters.saved_inserted, counters.saved_updated, + counters.dropped_novostroyki, ) runs.update_heartbeat(db, run_id, counters.to_dict()) runs.mark_done(db, run_id, {**counters.to_dict(), "done_buckets": sorted(done)}) 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..701d7ecb 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 @@ -231,6 +231,16 @@ _FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip" # карточки (с data-marker="item-development-name"). Извлечён из реального # search-URL: /ekaterinburg/kvartiry/prodam/novostroyka-ASgBAgICAkSSA8YQ5geOUg NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg" +# #3033: родной фильтр ВТОРИЧКИ. Каноничный slug категории (снят с живого браузера +# 21.08.2026) — vtorichka-ASgBAgICAkSSA8YQ5geMUg: отличается от NOVOSTROYKA_SLUG одним +# символом (…5geMUg vs …5geOUg, соседние значения того же enum). Для комбинации +# «комнатность × вторичка» бинарный хвост собирать не нужно: Авито принимает ЧИТАЕМЫЕ +# сегменты пути (/{city}/kvartiry/prodam/1-komnatnye/vtorichka → редирект на канонический +# URL с той же выдачей, 2 551 объявление против 46 573 в общей — проверено вживую), поэтому +# в secondary-режиме _build_rooms_url строит путь из читаемого префикса room-slug + этот +# сегмент. Цена вопроса: вторичка — 20,6 % общей выдачи ЕКБ, остальное раньше качалось +# и отбрасывалось после разбора (страницы и антибан-бюджет уже потрачены). +SECONDARY_PATH_SEGMENT = "vtorichka" # ── Exhaustive full-load (room×price bisection) ─────────────────────────────── # Верхняя граница цены при первом рекурсивном делении (нет явного hi). @@ -375,6 +385,14 @@ class AvitoScraper(BaseScraper): # None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only, # чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/. self._target_city_slug = target_city_slug + # #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время + # fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы + # остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча. + self._secondary_path: bool = False + # #3033: сколько новостроек отбросил post-parse фильтр за последний + # fetch_all_secondary — при родном фильтре ожидается ≈0; рост = Авито перестал + # уважать путь (редирект на общую выдачу), и это видно по счётчику, а не по тишине. + self.last_dropped_nb: int = 0 async def __aenter__(self) -> AvitoScraper: # Проактивная changeip-ротация в начале sweep (#1731) снята #2616 шаг 2 — @@ -940,7 +958,15 @@ class AvitoScraper(BaseScraper): 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)}" + if self._secondary_path: + # #3033: читаемый префикс room-slug ("1-komnatnye", "studii", …) + /vtorichka. + # Авито канонизирует сам (редирект) — хвост ASgB здесь не нужен и не известен + # для комбинации «комнатность × вторичка». + readable = room_slug.split("-ASgB", 1)[0] + path = f"/{self._city_seg()}/kvartiry/prodam/{readable}/{SECONDARY_PATH_SEGMENT}" + else: + path = f"/{self._city_seg()}/kvartiry/prodam/{room_slug}" + return f"{self.base_url}{path}?{urlencode(params)}" def _extract_total_count(self, html: str) -> int | None: """Извлечь общее число результатов из Avito SERP (`page-title/count`). @@ -1108,6 +1134,42 @@ class AvitoScraper(BaseScraper): """ _buckets = rooms_buckets if rooms_buckets is not None else _AVITO_DEFAULT_ROOMS seen: dict[str, ScrapedLot] = {} + # #3033: родной фильтр вторички в URL на время обхода; post-parse фильтр ниже + # остаётся страховкой и счётчиком (last_dropped_nb ≈ 0 при работающем пути). + self._secondary_path = bool(secondary_only) + self.last_dropped_nb = 0 + try: + return await self._fetch_all_secondary_inner( + _buckets=_buckets, + seen=seen, + price_cap_per_bucket=price_cap_per_bucket, + max_pages_per_bucket=max_pages_per_bucket, + concurrency=concurrency, + secondary_only=secondary_only, + on_bucket=on_bucket, + on_progress=on_progress, + skip_buckets=skip_buckets, + since=since, + ) + finally: + self._secondary_path = False + + async def _fetch_all_secondary_inner( + self, + *, + _buckets: list[tuple[str, str]], + seen: dict[str, ScrapedLot], + price_cap_per_bucket: int, + max_pages_per_bucket: int, + concurrency: int, + secondary_only: bool, + on_bucket: Callable[..., Any] | None, + on_progress: Callable[[int], None] | None, + skip_buckets: set[str] | None, + since: date | None, + ) -> list[ScrapedLot]: + """Тело fetch_all_secondary — вынесено, чтобы режим _secondary_path гарантированно + сбрасывался в finally обёртки при любом исходе (#3033).""" # Tolerance к одиночным per-bucket блокам (deep-pagination 429 dense-бакета): # скипаем заблокированный бакет и продолжаем; N ПОДРЯД блоков = hard ban → re-raise. consecutive_blocked = 0 @@ -1463,6 +1525,7 @@ class AvitoScraper(BaseScraper): if secondary_only: filtered = [lot for lot in bucket_lots if lot.listing_segment != "novostroyki"] dropped_nb = collected_this_bucket - len(filtered) + self.last_dropped_nb += dropped_nb bucket_lots = filtered # Дедуп в общий seen @@ -1621,6 +1684,7 @@ class AvitoScraper(BaseScraper): if secondary_only: filtered = [lot for lot in bucket_lots if lot.listing_segment != "novostroyki"] dropped_nb = collected_this_bucket - len(filtered) + self.last_dropped_nb += dropped_nb bucket_lots = filtered # Дедуп в общий seen -- 2.45.3