feat(tradein/avito): путь вторички для якорного и citywide-обхода (#3033, follow-up) (#3042)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 2m21s
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m41s
Deploy Trade-In / build-backend (push) Successful in 1m40s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 2m21s
This commit is contained in:
parent
17a6dae3a4
commit
bebeb0c8f0
2 changed files with 97 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue