Merge remote-tracking branch 'forgejo/main' into fix/3034-avito-fingerprint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m28s

This commit is contained in:
bot-backend 2026-08-21 17:27:52 +03:00
commit fc2930fbb6
3 changed files with 170 additions and 2 deletions

View file

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

View file

@ -3762,6 +3762,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)}
@ -3919,13 +3923,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)})

View file

@ -232,6 +232,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).
@ -376,6 +386,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 —
@ -941,7 +959,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`).
@ -1109,6 +1135,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
@ -1464,6 +1526,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
@ -1622,6 +1685,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