Compare commits
1 commit
main
...
feat/3033-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a167ca30b6 |
2 changed files with 225 additions and 7 deletions
|
|
@ -0,0 +1,168 @@
|
|||
"""Avito room-URL строится по родному фильтру вторички, а не общей выборке (#3033).
|
||||
|
||||
Замер 2026-08-21 (живой сайт, ЕКБ): общая выборка `prodam-ASgBAgICAUSSA8YQ` = 46 573
|
||||
объявления, из них вторичка `prodam/vtorichka-...` = 9 612 (20.6%). Текущий код
|
||||
(`fetch_all_secondary`) качал ВСЕ 46 573, парсил и отбрасывал ~79% новостроек после
|
||||
парсинга (`listing_segment == "novostroyki"`). Родной Avito-фильтр вторички —
|
||||
`/{city}/kvartiry/prodam/{room-readable}/vtorichka` — отдаёт сразу нужную выборку,
|
||||
сокращая объём обхода ~впятеро.
|
||||
|
||||
Ключевое: декодировать бинарный ASgB-хвост НЕ нужно. Проверено вживую через camoufox
|
||||
(vault research/Avito_Live_Browser_Recon_0821, 2026-08-21): заход на читаемый путь без
|
||||
хвоста (`/ekaterinburg/kvartiry/prodam/1-komnatnye/vtorichka`) отдаёт канонический URL
|
||||
и правильную выдачу (2551 в первом замере, 2364/2549 на повторных live-прогонах —
|
||||
листинги живые, число плавает). Читаемый префикс уже лежит в ROOM_SLUGS до маркера
|
||||
`-ASgB` (`studii`, `1-komnatnye`, ..., `svobodnaya_planirovka`).
|
||||
|
||||
`secondary_only`-фильтр после парсинга (`fetch_all_secondary`) НЕ убран — остаётся
|
||||
страховкой на случай, если Avito протащит новостройку в выдачу вторички; ассерты на
|
||||
него здесь не нужны, он тестируется отдельно (#1781-style).
|
||||
|
||||
Пороговые данные из того же live-прогона (2026-08-21, camoufox, реальный /fetch через
|
||||
tradein-browser sidecar): на живой странице `/vtorichka` найдено 59-60 уникальных
|
||||
`data-item-id` карточек, а НЕ 50 — `_AVITO_OFFERS_PER_PAGE` был занижен на 20%,
|
||||
что недосчитывало страниц в `pages_needed = ceil(total / _AVITO_OFFERS_PER_PAGE)`
|
||||
(тихий tail-loss на каждом leaf-бакете бисекции).
|
||||
|
||||
No network / no DB — чистые URL-билдеры на stub-конфиге, как test_serp_city_location_urls.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from scraper_kit.providers.avito.serp import (
|
||||
_AVITO_OFFERS_PER_PAGE,
|
||||
NOVOSTROYKA_SLUG,
|
||||
ROOM_SLUGS,
|
||||
AvitoScraper,
|
||||
_room_readable_prefix,
|
||||
)
|
||||
|
||||
_STUB_CFG = SimpleNamespace()
|
||||
|
||||
|
||||
# ── _room_readable_prefix ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("slug", "expected"),
|
||||
[
|
||||
("studii-ASgBAgICAkSSA8YQygj~WA", "studii"),
|
||||
("1-komnatnye-ASgBAgICAkSSA8YQygiAWQ", "1-komnatnye"),
|
||||
("2-komnatnye-ASgBAgICAkSSA8YQygiCWQ", "2-komnatnye"),
|
||||
("3-komnatnye-ASgBAgICAkSSA8YQygiEWQ", "3-komnatnye"),
|
||||
("4-komnatnye-ASgBAgICAkSSA8YQygiGWQ", "4-komnatnye"),
|
||||
("5-komnatnye-ASgBAgICAkSSA8YQygiIWQ", "5-komnatnye"),
|
||||
("svobodnaya_planirovka-ASgBAgICAkSSA8YQygj8zzI", "svobodnaya_planirovka"),
|
||||
],
|
||||
)
|
||||
def test_room_readable_prefix_strips_asgb_tail(slug: str, expected: str) -> None:
|
||||
assert _room_readable_prefix(slug) == expected
|
||||
|
||||
|
||||
def test_room_readable_prefix_is_noop_without_asgb_marker() -> None:
|
||||
"""Строки без маркера (тестовые заглушки типа "2k") возвращаются как есть."""
|
||||
assert _room_readable_prefix("2k") == "2k"
|
||||
|
||||
|
||||
def test_room_slugs_readable_prefixes_match_expected_avito_segments() -> None:
|
||||
"""ROOM_SLUGS покрывает все 7 комнатностей ожидаемыми читаемыми префиксами."""
|
||||
expected = {
|
||||
"studii",
|
||||
"1-komnatnye",
|
||||
"2-komnatnye",
|
||||
"3-komnatnye",
|
||||
"4-komnatnye",
|
||||
"5-komnatnye",
|
||||
"svobodnaya_planirovka",
|
||||
}
|
||||
assert len(ROOM_SLUGS) == 7
|
||||
actual = {_room_readable_prefix(slug) for _, slug in ROOM_SLUGS}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
# ── _build_rooms_url: путь и query для всех комнатностей × pmin/pmax ─────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS)
|
||||
def test_build_rooms_url_uses_readable_vtorichka_path(name: str, slug: str) -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
url = s._build_rooms_url(slug, page=1)
|
||||
readable = _room_readable_prefix(slug)
|
||||
assert f"/ekaterinburg/kvartiry/prodam/{readable}/vtorichka" in url
|
||||
# Бинарный ASgB-хвост декодировать не нужно — его не должно быть в пути.
|
||||
assert "-ASgB" not in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS)
|
||||
def test_build_rooms_url_without_price_bounds_omits_pmin_pmax(name: str, slug: str) -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
url = s._build_rooms_url(slug, page=1)
|
||||
assert "pmin=" not in url
|
||||
assert "pmax=" not in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS)
|
||||
def test_build_rooms_url_with_price_bounds_includes_pmin_pmax(name: str, slug: str) -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
url = s._build_rooms_url(slug, page=3, min_price=3_000_000, max_price=5_000_000)
|
||||
assert "pmin=3000000" in url
|
||||
assert "pmax=5000000" in url
|
||||
assert "p=3" in url
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "slug"), ROOM_SLUGS)
|
||||
def test_build_rooms_url_with_only_min_price_omits_pmax(name: str, slug: str) -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
url = s._build_rooms_url(slug, page=1, min_price=1_000_000)
|
||||
assert "pmin=1000000" in url
|
||||
assert "pmax=" not in url
|
||||
|
||||
|
||||
# ── Вторичка ≠ новостройки ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_vtorichka_room_url_differs_from_newbuilding_url() -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
room_url = s._build_rooms_url(ROOM_SLUGS[1][1], page=1) # "1-komnatnye"
|
||||
newbuilding_url = s._build_newbuilding_url(page=1)
|
||||
assert room_url != newbuilding_url
|
||||
assert "/vtorichka" in room_url
|
||||
assert "/vtorichka" not in newbuilding_url
|
||||
assert NOVOSTROYKA_SLUG in newbuilding_url
|
||||
assert NOVOSTROYKA_SLUG not in room_url
|
||||
|
||||
|
||||
# ── Областные города строят корректный путь ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"city_slug",
|
||||
["nizhniy_tagil", "kamensk_uralskiy", "pervouralsk", "verkhnyaya_pyshma", "serov"],
|
||||
)
|
||||
def test_build_rooms_url_oblast_city_uses_target_city_slug_in_path(city_slug: str) -> None:
|
||||
"""Свип по областным городам использует target_city_slug в пути, не ЕКБ."""
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=city_slug) # type: ignore[arg-type]
|
||||
url = s._build_rooms_url(ROOM_SLUGS[1][1], page=1) # "1-komnatnye"
|
||||
assert f"/{city_slug}/kvartiry/prodam/1-komnatnye/vtorichka" in url
|
||||
assert "/ekaterinburg/" not in url
|
||||
|
||||
|
||||
def test_build_rooms_url_defaults_to_ekaterinburg_without_target_city_slug() -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
url = s._build_rooms_url(ROOM_SLUGS[0][1], page=1) # "studii"
|
||||
assert "/ekaterinburg/kvartiry/prodam/studii/vtorichka" in url
|
||||
|
||||
|
||||
# ── Регрессия: карточек на страницу 60, не 50 (замер 2026-08-21 живьём) ──────────
|
||||
|
||||
|
||||
def test_offers_per_page_constant_is_sixty_not_fifty() -> None:
|
||||
"""`_AVITO_OFFERS_PER_PAGE` управляет `pages_needed` в бисекции — недооценка на
|
||||
20% (50 вместо 60) тихо теряла хвост каждого leaf-бакета."""
|
||||
assert _AVITO_OFFERS_PER_PAGE == 60
|
||||
|
|
@ -14,8 +14,11 @@ URL patterns (EKB):
|
|||
https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ
|
||||
?s=104&p=1
|
||||
|
||||
byrooms-mode (citywide + фильтр комнатности, T6):
|
||||
https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-slug>?s=104&p=1
|
||||
byrooms-mode / exhaustive-mode (citywide + фильтр комнатности + вторичка, T6/#3033):
|
||||
https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-readable>/vtorichka
|
||||
?s=104&p=1[&pmin=...][&pmax=...]
|
||||
<room-readable> — читаемый префикс ROOM_SLUGS до ASgB-хвоста (напр. "1-komnatnye"),
|
||||
декодировать бинарный хвост не нужно — Avito канонизирует путь сам.
|
||||
|
||||
ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
|
||||
Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello.
|
||||
|
|
@ -250,8 +253,15 @@ _AVITO_MIN_BRACKET = 50_000
|
|||
# Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса
|
||||
# без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции.
|
||||
_AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets()
|
||||
# Avito SERP показывает ~50 карточек на страницу.
|
||||
_AVITO_OFFERS_PER_PAGE = 50
|
||||
# Avito SERP показывает 60 карточек на страницу, НЕ 50 (подтверждено вживую 2026-08-21
|
||||
# через camoufox: подсчёт `[data-marker="item"]` на живой странице /vtorichka дал 59-60
|
||||
# уникальных `data-item-id` на нескольких прогонах; "лишние" карточки несут CSS-класс
|
||||
# `vas-icon_type-promoted` — это обычные объявления с платным продвижением (VAS), НЕ
|
||||
# инжектированный рекламный блок/секция "похожие" — все они внутри одного
|
||||
# `page-title/count`-списка. Константа была занижена на 20%: `pages_needed =
|
||||
# ceil(total/_AVITO_OFFERS_PER_PAGE)` при 50 недосчитывала страниц на leaf-бакет,
|
||||
# см. `_walk_price_range`/`_paginate_leaf_bucket` — тихий tail-loss на каждом бакете.
|
||||
_AVITO_OFFERS_PER_PAGE = 60
|
||||
|
||||
|
||||
def _avito_bisection_config(cap: int) -> BisectionConfig:
|
||||
|
|
@ -926,21 +936,43 @@ class AvitoScraper(BaseScraper):
|
|||
min_price: int | None = None,
|
||||
max_price: int | None = None,
|
||||
) -> str:
|
||||
"""T6: URL с фильтром по комнатности для всего города (no geo).
|
||||
"""T6: URL вторички с фильтром по комнатности для всего города (no geo).
|
||||
|
||||
Дробление по комнатности позволяет обойти cap ~5000 на category.
|
||||
room_slug — берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки).
|
||||
|
||||
#3033: путь строится как `/{city}/kvartiry/prodam/{room_readable}/vtorichka`
|
||||
— родной фильтр вторички Avito, а НЕ общая выборка комнатности с
|
||||
отбрасыванием новостроек после парсинга (`secondary_only`). Вторичка — 20.6%
|
||||
общей выдачи ЕКБ (9612 из 46573, замер 2026-08-21), т.е. вместо скачивания и
|
||||
разбора всех карточек с последующим `listing_segment == "novostroyki"` фильтром
|
||||
качаем СРАЗУ ~впятеро меньший набор. `secondary_only`-фильтр после парсинга
|
||||
остаётся страховкой (см. fetch_all_secondary) на случай, если Avito протащит
|
||||
новостройку в выдачу вторички.
|
||||
|
||||
Декодировать бинарный ASgB-хвост НЕ нужно: читаемый префикс room_slug (до
|
||||
маркера `-ASgB`, см. `_room_readable_prefix`) уже валиден как самостоятельный
|
||||
Avito-путь — площадка сама канонизирует его на `/vtorichka` без бинарного
|
||||
хвоста (подтверждено вживую 2026-08-21 через camoufox: редирект отдаёт
|
||||
канонический URL и правильную выдачу; count/60-карточек/pmin-pmax подтверждены
|
||||
отдельно через тот же live-браузер-прогон, vault
|
||||
research/Avito_Live_Browser_Recon_0821).
|
||||
|
||||
min_price/max_price — опциональные ценовые границы (Avito `pmin`/`pmax`),
|
||||
добавляются в query ТОЛЬКО если заданы (backward-compat: вызовы без цены
|
||||
не меняют URL). Используются exhaustive full-load для room×price бисекции.
|
||||
не меняют форму query). Используются exhaustive full-load для room×price
|
||||
бисекции.
|
||||
"""
|
||||
params: dict[str, Any] = {"s": 104, "p": page}
|
||||
if min_price is not None:
|
||||
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)}"
|
||||
room_readable = _room_readable_prefix(room_slug)
|
||||
return (
|
||||
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam/{room_readable}/vtorichka"
|
||||
f"?{urlencode(params)}"
|
||||
)
|
||||
|
||||
def _extract_total_count(self, html: str) -> int | None:
|
||||
"""Извлечь общее число результатов из Avito SERP (`page-title/count`).
|
||||
|
|
@ -1091,6 +1123,9 @@ class AvitoScraper(BaseScraper):
|
|||
secondary_only: если True (default) — отбрасывает новостройки
|
||||
(listing_segment=="novostroyki") после парсинга, до save/on_bucket.
|
||||
Avito тегирует сегмент по DOM-маркеру item-development-name в _parse_html.
|
||||
#3033: URL бакета (`_build_rooms_url`) уже фильтрует вторичку на стороне
|
||||
Avito (`/vtorichka`-путь) — этот флаг теперь СТРАХОВКА на случай, если
|
||||
площадка протащит новостройку в выдачу вторички, а не основной фильтр.
|
||||
on_bucket: опциональный callback(bucket_key, list[ScrapedLot]) после каждого
|
||||
leaf-бакета. Может быть async или sync. Исключение прерывает прогон.
|
||||
on_progress: опциональный callback(unique_count) для heartbeat (per room-bucket).
|
||||
|
|
@ -2123,6 +2158,21 @@ ROOM_SLUGS: list[tuple[str, str]] = [
|
|||
# своб.планировку), т.к. каждая категория < SERP-cap после room×price бисекции.
|
||||
_AVITO_DEFAULT_ROOMS: list[tuple[str, str]] = list(ROOM_SLUGS)
|
||||
|
||||
# #3033: маркер начала бинарного ASgB-хвоста в ROOM_SLUGS-слаге (напр.
|
||||
# "1-komnatnye-ASgBAgICAkSSA8YQygiAWQ" → хвост кодирует параметры выборки, которые
|
||||
# Avito умеет разворачивать сам через canonical-редирект).
|
||||
_ROOM_SLUG_ASGB_MARKER = "-ASgB"
|
||||
|
||||
|
||||
def _room_readable_prefix(room_slug: str) -> str:
|
||||
"""Читаемый префикс комнатности из ROOM_SLUGS-слага (до `-ASgB`-хвоста).
|
||||
|
||||
"1-komnatnye-ASgBAgICAkSSA8YQygiAWQ" → "1-komnatnye", "studii-ASgB..." → "studii".
|
||||
Строки без маркера (напр. тестовые заглушки типа "2k") возвращаются как есть —
|
||||
split без совпадения отдаёт исходную строку целиком.
|
||||
"""
|
||||
return room_slug.split(_ROOM_SLUG_ASGB_MARKER, 1)[0]
|
||||
|
||||
_NON_SLUG_CHARS_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue