From faa4febc27ed5c5edb9c4531525e6e404b682a15 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 20:24:08 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein/avito):=20=D0=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=8F=D0=BA=D0=BE=D1=80=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B8=20citywide-=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20(#3033,=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: #3039 закрыл только fetch_all_secondary; якорный city-sweep (fetch_around → _build_web_url с geoCoords/radius) и fetch_city_wide остались на общей выдаче — прогон 4539 после деплоя принёс 41 vtorichka + 26 novostroyki. Предусловие владельца проверено вживую 21.08 через сайдкар: /prodam/vtorichka-ASgB… принимает geoCoords/radius (60 карточек, все vtorichka, total=9036 против 55 карточек 41/14 у общей выдачи). Что: SECONDARY_SLUG (канонический, без редиректа) в _category_path; _build_web_url/_build_citywide_url получают secondary=; fetch_around и fetch_city_wide — secondary_only=True по умолчанию (класс — парсер вторички, новостройки ходят своим fetch_newbuildings; админ-скрейп и пайплайн получают это автоматически). _build_newbuilding_url не тронут. Тесты: fetch_around/fetch_city_wide по умолчанию — slug вторички и сохранённые geoCoords/radius; secondary_only=False — прежний путь; путь новостроек не изменился. На main красный по значению. Побочно обнаружено и вынесено в отдельную issue: geoCoords/radius Авито на сервере НЕ фильтрует — три якоря в 10+ км дали одну и ту же страницу. Refs #3033 --- ...test_3033_avito_native_secondary_filter.py | 60 +++++++++++++++++++ .../src/scraper_kit/providers/avito/serp.py | 47 +++++++++++---- 2 files changed, 95 insertions(+), 12 deletions(-) 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 index 7e4c3dae..7591052a 100644 --- a/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py +++ b/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py @@ -95,3 +95,63 @@ def test_pipeline_counters_carry_dropped_novostroyki() -> None: from scraper_kit.orchestration.pipeline import AvitoFullLoadCounters assert "dropped_novostroyki" in AvitoFullLoadCounters().to_dict() + + +# ── follow-up (#3033, комментарий владельца 21.08 15:02): якорный и citywide-пути ────────── +# Проверено вживую 21.08 через сайдкар: /prodam/vtorichka-ASgB… принимает geoCoords/radius +# (60 карточек, все vtorichka, total=9036 против 55 карточек 41/14 у общей выдачи). +from scraper_kit.providers.avito.serp import SECONDARY_SLUG # noqa: E402 + + +def _card(item_id: str) -> str: + href = f"/ekaterinburg/kvartiry/2k_kvartira_50_m_5_5et_{item_id}" + return ( + f'
' + f'2-к. квартира, 50 м², 5/9 эт.' + f'
' + ) + + +def _capture(method: str, **kw: object) -> list[str]: + s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type] + urls: list[str] = [] + + async def fake_fetch(url: str, page: int) -> str: + urls.append(url) + return "" + _card(f"id{len(urls)}") + "" + + s._fetch_serp_html = fake_fetch # type: ignore[method-assign] + asyncio.run(getattr(s, method)(**kw)) + return urls + + +def test_anchor_sweep_uses_secondary_slug_by_default() -> None: + """fetch_around по умолчанию — путь вторички, geoCoords/radius сохранены в query.""" + urls = _capture("fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1) + assert urls, "fetch_around не сделал запроса" + u = urlparse(urls[0]) + assert u.path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}", u.path + q = parse_qs(u.query) + assert q["geoCoords"] == ["56.84,60.6"] and q["radius"] == ["1"] and q["s"] == ["104"] + + +def test_anchor_sweep_secondary_only_false_keeps_general_path() -> None: + """Контроль: secondary_only=False — прежняя общая выдача.""" + urls = _capture( + "fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, secondary_only=False + ) + assert urlparse(urls[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ" + + +def test_citywide_uses_secondary_slug_by_default() -> None: + """fetch_city_wide по умолчанию — путь вторички; secondary_only=False — общая выдача.""" + urls = _capture("fetch_city_wide", pages=1) + assert urlparse(urls[0]).path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}" + urls2 = _capture("fetch_city_wide", pages=1, secondary_only=False) + assert urlparse(urls2[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ" + + +def test_newbuilding_builder_untouched() -> None: + """Контроль: путь новостроек не меняется (свой slug, свой sweep).""" + s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type] + assert "novostroyka-ASgBAgICAkSSA8YQ5geOUg" in s._build_newbuilding_url(1) 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 c7526780..7daf9c09 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 @@ -241,6 +241,11 @@ NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg" # сегмент. Цена вопроса: вторичка — 20,6 % общей выдачи ЕКБ, остальное раньше качалось # и отбрасывалось после разбора (страницы и антибан-бюджет уже потрачены). SECONDARY_PATH_SEGMENT = "vtorichka" +# Канонический slug категории «вторичка» — для путей БЕЗ комнатности (якорный +# fetch_around и citywide): редиректа не нужно, запрос один. Проверено вживую 21.08.2026 +# через сайдкар с geoCoords/radius: 60 карточек, все vtorichka, total=9036 (против 55 карточек +# 41/14 у общей выдачи на том же якоре) — гео-параметры путь принимает. +SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg" # ── Exhaustive full-load (room×price bisection) ─────────────────────────────── # Верхняя граница цены при первом рекурсивном делении (нет явного hi). @@ -842,9 +847,14 @@ class AvitoScraper(BaseScraper): *, pages: int = 1, delay_override_sec: float | None = None, + secondary_only: bool = True, ) -> list[ScrapedLot]: """Найти объявления Авито вокруг (lat, lon) в radius_m метрах. + secondary_only (default True, #3033): путь вторички вместо общей выдачи — класс и + есть парсер вторички, новостройки ходят своим fetch_newbuildings. До этого якорный + city-sweep качал общую выдачу (в прогоне 4539: 41 vtorichka + 26 novostroyki). + Avito работает в км — конвертируем. Минимум 1 км. pages=1 (default) — backward compat, одна страница (~50 lots). @@ -861,7 +871,7 @@ class AvitoScraper(BaseScraper): all_lots: list[ScrapedLot] = [] for page in range(1, pages + 1): - url = self._build_web_url(lat, lon, radius_km, page=page) + url = self._build_web_url(lat, lon, radius_km, page=page, secondary=secondary_only) try: html = await self._fetch_serp_html(url, page) except (AvitoBlockedError, AvitoRateLimitedError): @@ -918,28 +928,38 @@ class AvitoScraper(BaseScraper): билдерах скоупил oblast city-sweep на ЕКБ, невзирая на geoCoords/фильтр).""" return self._target_city_slug or "ekaterinburg" - def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: + def _category_path(self, *, secondary: bool) -> str: + """Сегмент категории: родной фильтр вторички (#3033) либо общая выдача.""" + if secondary: + return f"/{self._city_seg()}/kvartiry/prodam/{SECONDARY_SLUG}" + return f"/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ" + + def _build_web_url( + self, + lat: float, + lon: float, + radius_km: int, + page: int = 1, + *, + secondary: bool = False, + ) -> str: + """URL якорного поиска (geoCoords + radius). secondary=True — путь вторички (#3033).""" params = { "geoCoords": f"{lat},{lon}", "radius": radius_km, "s": 104, # sort by date "p": page, } - return ( - f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ" - f"?{urlencode(params)}" - ) + return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}" - def _build_citywide_url(self, page: int = 1) -> str: + def _build_citywide_url(self, page: int = 1, *, secondary: bool = False) -> str: """T6: URL всего города (по умолчанию ЕКБ) без geo-фильтра, сортировка по дате. Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек). + secondary=True — путь вторички (#3033): cap тот же, но выдача в 5 раз уже. """ params = {"s": 104, "p": page} - return ( - f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ" - f"?{urlencode(params)}" - ) + return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}" def _build_newbuilding_url(self, page: int = 1) -> str: """URL городской выборки только новостроек (novostroyka-filter), сортировка по дате. @@ -1735,9 +1755,12 @@ class AvitoScraper(BaseScraper): pages: int = 100, *, delay_override_sec: float | None = None, + secondary_only: bool = True, ) -> list[ScrapedLot]: """T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated. + secondary_only (default True, #3033): путь вторички вместо общей выдачи. + Возвращает дедуплицированный список лотов (по source_id/source_url). Break-on-empty: останавливается когда страница отдаёт 0 карточек. Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect, @@ -1753,7 +1776,7 @@ class AvitoScraper(BaseScraper): """ all_lots = await self._paginate_sweep( pages, - self._build_citywide_url, + lambda page: self._build_citywide_url(page, secondary=secondary_only), label="citywide", delay_override_sec=delay_override_sec, ) -- 2.45.3 From 65e8eb08ab1b6551ffa68160e0e07832a924d874 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 21 Aug 2026 20:24:57 +0500 Subject: [PATCH 2/2] =?UTF-8?q?test(tradein):=20#3033=20follow-up=20?= =?UTF-8?q?=E2=80=94=20slug=20=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B5=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D0=B0=D0=BB=D0=BE=D0=BC,=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20=D0=BD=D0=B0=20main=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BA=D1=80=D0=B0=D1=81=D0=BD=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8E,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BD=D0=B0=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/tests/test_3033_avito_native_secondary_filter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 7591052a..92a5a630 100644 --- a/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py +++ b/tradein-mvp/backend/tests/test_3033_avito_native_secondary_filter.py @@ -100,7 +100,9 @@ def test_pipeline_counters_carry_dropped_novostroyki() -> None: # ── follow-up (#3033, комментарий владельца 21.08 15:02): якорный и citywide-пути ────────── # Проверено вживую 21.08 через сайдкар: /prodam/vtorichka-ASgB… принимает geoCoords/radius # (60 карточек, все vtorichka, total=9036 против 55 карточек 41/14 у общей выдачи). -from scraper_kit.providers.avito.serp import SECONDARY_SLUG # noqa: E402 +# Литерал, а не импорт: на main до правки имени SECONDARY_SLUG нет, и импорт ронял бы весь +# файл «нет возможности», а не «неверное значение». Slug — из постановки #3033. +SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg" def _card(item_id: str) -> str: -- 2.45.3