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..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
@@ -95,3 +95,65 @@ 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 у общей выдачи).
+# Литерал, а не импорт: на main до правки имени SECONDARY_SLUG нет, и импорт ронял бы весь
+# файл «нет возможности», а не «неверное значение». Slug — из постановки #3033.
+SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg"
+
+
+def _card(item_id: str) -> str:
+ href = f"/ekaterinburg/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
+ return (
+ 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,
)