feat(tradein/avito): путь вторички для якорного и citywide-обхода (#3033, follow-up) #3042

Merged
bot-backend merged 2 commits from feat/3033-anchor-citywide-secondary into main 2026-08-21 15:33:23 +00:00
2 changed files with 97 additions and 12 deletions

View file

@ -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'<div data-marker="item" data-item-id="{item_id}">'
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
f'<meta itemprop="price" content="5000000"></div>'
)
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 "<html><body>" + _card(f"id{len(urls)}") + "</body></html>"
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)

View file

@ -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,
)