feat(tradein/avito): полный обход качает вторичку родным фильтром пути (#3033) #3039
3 changed files with 170 additions and 2 deletions
|
|
@ -0,0 +1,97 @@
|
|||
"""Полный обход Авито качает ВТОРИЧКУ родным фильтром пути, а не всё подряд (#3033).
|
||||
|
||||
До правки `fetch_all_secondary(secondary_only=True)` строил URL общей выдачи
|
||||
(`/kvartiry/prodam/<room-ASgB…>`), скачивал 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 = '<html><span data-marker="page-title/count">0</span></html>'
|
||||
|
||||
|
||||
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()
|
||||
|
|
@ -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)})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue