feat(tradein): Avito mobile proxy egress + firewall detect + MSK date (#623, #726)

- avito.py: AVITO_PROXY_URL → proxies в curl_cffi AsyncSession; firewall-детект
  «Доступ ограничен: проблема с IP» на HTTP 200 → AvitoBlockedError (раньше бан
  проходил молча как 0-cards); ротация мобильного IP через changeip-ссылку
  (_rotate_ip) с retry до avito_proxy_max_rotations; transient-retry на сетевых
  обрывах мобильного канала.
- config.py: avito_proxy_url / avito_proxy_rotate_url / avito_proxy_max_rotations.
- #726: sortTimeStamp → дата в MSK (UTC+3 fixed offset), а не UTC — совпадает с
  датой на сайте; off-by-one для постов 00:00–03:00 MSK устранён.
- tests: firewall-детект (HTTP 200), ротация+повтор, exhausted-rotation; serp_date
  переведён на MSK.

Live-verified с прод-сервера: mobile proxy снимает firewall (0→48 карточек),
ротация IP 188.162.65.168→94.25.229.234. Без env AVITO_PROXY_URL поведение не
меняется (dev). Требует deploy (#659) + env AVITO_PROXY_URL/ROTATE_URL на VPS.
This commit is contained in:
lekss361 2026-05-30 19:49:09 +03:00
parent 8f2ff10a1c
commit 147c36d448
4 changed files with 237 additions and 20 deletions

View file

@ -124,5 +124,16 @@ class Settings(BaseSettings):
"n1.ru",
}
# ── Avito mobile proxy (#623) ─────────────────────────────────────────────
# Avito банит datacenter-IP сервера — на HTTP 200 отдаёт firewall-страницу
# «Доступ ограничен: проблема с IP». Мобильный прокси (mobileproxy.space)
# снимает бан. Пусто = прямое подключение (dev). ENV: AVITO_PROXY_URL.
avito_proxy_url: str | None = None
# changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
# детекте бана перед повтором. Пусто = ротация выключена (raise сразу).
avito_proxy_rotate_url: str | None = None
# Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
avito_proxy_max_rotations: int = 2
settings = Settings()

View file

@ -15,15 +15,17 @@ URL pattern (EKB):
from __future__ import annotations
import asyncio
import logging
import re
from datetime import UTC, date, datetime, timedelta
from datetime import date, datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode, urljoin
from curl_cffi.requests import AsyncSession
from selectolax.parser import HTMLParser
from app.core.config import settings
from app.services.scraper_settings import get_scraper_delay
from app.services.scrapers.avito_exceptions import (
AvitoBlockedError,
@ -151,6 +153,11 @@ def _parse_relative_date(s: str | None) -> date | None:
# Маппим item_id (= DOM `data-item-id`) → дата. `{0,1500}` ограничивает поиск телом
# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный
# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — sortTimeStamp может быть на новой строке.
# sortTimeStamp — UTC epoch-ms. Avito показывает дату в MSK (UTC+3, без DST с 2014).
# Берём .date() в MSK, чтобы совпадало с тем, что видно на сайте (иначе объявления,
# поднятые в 00:0003:00 MSK, давали бы дату на день раньше). Fixed offset, без
# зависимости от системной tzdata в контейнере. #726.
_MSK = timezone(timedelta(hours=3))
_SORT_TS_RE = re.compile(r'"id":(?P<id>\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P<ts>\d+)', re.S)
@ -166,12 +173,24 @@ def _build_sort_timestamp_map(html: str) -> dict[str, date]:
if item_id in out:
continue
try:
out[item_id] = datetime.fromtimestamp(int(m.group("ts")) / 1000, tz=UTC).date()
out[item_id] = datetime.fromtimestamp(int(m.group("ts")) / 1000, tz=_MSK).date()
except (ValueError, OSError, OverflowError):
continue
return out
# #623: Avito банит datacenter-IP — на HTTP 200 отдаёт firewall-интерстициал
# «Доступ ограничен: проблема с IP» (title в <head>), а НЕ 403. Детектим по
# маркерам в начале документа (firewall-страница объёмна, не сканируем целиком).
_FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container")
def _is_firewall_page(html: str) -> bool:
"""True если Avito вернул firewall-страницу IP-блока (на HTTP 200)."""
head = html[:4096].lower()
return any(marker in head for marker in _FIREWALL_MARKERS)
class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'.
@ -191,9 +210,14 @@ class AvitoScraper(BaseScraper):
async def __aenter__(self) -> AvitoScraper:
await super().__aenter__()
proxies = None
if settings.avito_proxy_url:
proxies = {"http": settings.avito_proxy_url, "https": settings.avito_proxy_url}
logger.info("avito: routing through mobile proxy egress (#623)")
self._cffi = AsyncSession(
impersonate="chrome120",
timeout=25,
proxies=proxies,
headers={
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
@ -212,6 +236,94 @@ class AvitoScraper(BaseScraper):
await self._cffi.close()
await super().__aexit__(*args)
# ── Anti-block (#623) ─────────────────────────────────────────────────────
async def _rotate_ip(self) -> bool:
"""Сменить мобильный IP через changeip-ссылку mobileproxy (#623).
Дёргается напрямую (без прокси) это API провайдера, не Avito. Ждём ~9с:
мобильному модему нужно время поднять новый IP. Returns True при успехе.
"""
rotate_url = settings.avito_proxy_rotate_url
if not rotate_url:
return False
sep = "&" if "?" in rotate_url else "?"
try:
async with AsyncSession(timeout=30) as rot:
await rot.get(f"{rotate_url}{sep}format=json")
await asyncio.sleep(9)
logger.info("avito proxy: IP rotated via changeip")
return True
except Exception:
logger.warning("avito proxy: IP rotation failed", exc_info=True)
return False
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
"""GET SERP HTML с детектом IP-бана и ротацией мобильного IP (#623).
Avito на забаненном datacenter-IP отдаёт HTTP 200 + firewall-страницу
(не 403). Детектим по тексту; если задана changeip-ссылка меняем IP и
повторяем до ``avito_proxy_max_rotations`` раз. Транзиентные сетевые
обрывы (мобильный канал) ретраим без ротации.
Returns:
HTML при HTTP 200, либо ``None`` при non-200 (конец пагинации).
Raises:
AvitoBlockedError / AvitoRateLimitedError если бан не снят ротацией.
"""
assert self._cffi is not None
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
rot_done = 0
transient_left = 2
while True:
try:
response = await self._cffi.get(url)
except Exception:
if transient_left > 0:
transient_left -= 1
logger.warning(
"avito page=%d transient fetch error — retry (left=%d)",
page,
transient_left,
exc_info=True,
)
await asyncio.sleep(3)
continue
raise
sc = response.status_code
is_firewall = sc == 200 and _is_firewall_page(response.text)
if sc in (403, 429) or is_firewall:
if rot_done < max_rot and await self._rotate_ip():
rot_done += 1
logger.info(
"avito page=%d blocked (HTTP %d, firewall=%s) — retry after rotation #%d",
page,
sc,
is_firewall,
rot_done,
)
continue
if sc == 429:
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
raise AvitoRateLimitedError(
f"Avito SERP returned 429 — rate limited at page={page}"
)
logger.error(
"avito SERP blocked page=%d (HTTP %d, firewall=%s) url=%s",
page,
sc,
is_firewall,
url,
)
raise AvitoBlockedError(
f"Avito SERP blocked (HTTP {sc}, firewall={is_firewall}) "
f"at page={page} — IP banned"
)
if sc != 200:
logger.warning("avito HTML page=%d returned %d for %s", page, sc, url)
return None
return response.text
# ── Public ──────────────────────────────────────────────────────────────
async def fetch_around(
self,
@ -242,28 +354,16 @@ class AvitoScraper(BaseScraper):
for page in range(1, pages + 1):
url = self._build_web_url(lat, lon, radius_km, page=page)
try:
assert self._cffi is not None
response = await self._cffi.get(url)
if response.status_code == 403:
logger.error("avito SERP HTTP 403 (IP blocked) page=%d url=%s", page, url)
raise AvitoBlockedError(f"Avito SERP returned 403 — IP blocked at page={page}")
if response.status_code == 429:
logger.error("avito SERP HTTP 429 (rate limited) page=%d url=%s", page, url)
raise AvitoRateLimitedError(
f"Avito SERP returned 429 — rate limited at page={page}"
)
if response.status_code != 200:
logger.warning(
"avito HTML page=%d returned %d for %s", page, response.status_code, url
)
break
html = await self._fetch_serp_html(url, page)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception:
logger.exception("avito HTML page=%d fetch failed for %s", page, url)
break
if html is None:
break
lots = self._parse_html(response.text, source_url_base=url)
lots = self._parse_html(html, source_url_base=url)
if not lots:
if page == 1:
logger.error(

View file

@ -309,3 +309,105 @@ async def test_city_sweep_marks_banned_on_block() -> None:
assert mock_runs.mark_banned.called
assert not mock_runs.mark_done.called
assert result.anchors_done == 1
# ── #623 firewall-детект + ротация IP ────────────────────────────
_CARD_HTML = (
"<html><body>"
'<div data-marker="item" data-item-id="123456789">'
'<a data-marker="item-title" href="/ekaterinburg/kvartiry/test-123456789">'
"2-к. квартира, 54,3 м², 5/9 эт."
"</a>"
'<meta itemprop="price" content="4500000"/>'
"</div>"
"</body></html>"
)
# Firewall-интерстициал Avito (HTTP 200, не 403) — IP-блок datacenter.
_FIREWALL_HTML = (
"<html><head><title>Доступ ограничен: проблема с IP</title></head>"
'<body><div class="firewall-container">...</div></body></html>'
)
@pytest.mark.asyncio
async def test_fetch_around_raises_blocked_on_firewall_html_200() -> None:
"""HTTP 200 + firewall-страница «Доступ ограничен» → AvitoBlockedError (#623).
Avito банит datacenter-IP отдачей firewall-интерстициала с кодом 200 (не 403)
раньше это молча проходило как «0 cards / content-block», теперь явный IP-блок.
"""
from app.services.scrapers.avito import AvitoScraper
scraper = AvitoScraper()
mock_session = AsyncMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = _FIREWALL_HTML
mock_session.get = AsyncMock(return_value=mock_response)
scraper._cffi = mock_session
with pytest.raises(AvitoBlockedError, match="firewall"):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
@pytest.mark.asyncio
async def test_fetch_around_rotates_ip_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
"""При firewall + заданной changeip-ссылке: ротация IP и повтор страницы (#623)."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
monkeypatch.setattr(
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
)
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
scraper = AvitoScraper()
fw = MagicMock()
fw.status_code = 200
fw.text = _FIREWALL_HTML
ok = MagicMock()
ok.status_code = 200
ok.text = _CARD_HTML
mock_session = AsyncMock()
mock_session.get = AsyncMock(side_effect=[fw, ok])
scraper._cffi = mock_session
with (
patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot,
patch.object(AvitoScraper, "sleep_between_requests", AsyncMock()),
):
result = await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
rot.assert_awaited_once()
assert len(result) == 1
assert mock_session.get.await_count == 2 # firewall → rotate → retry OK
@pytest.mark.asyncio
async def test_fetch_around_blocked_when_rotation_exhausted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Если ротация не сняла бан (max_rotations исчерпан) → AvitoBlockedError (#623)."""
from app.services.scrapers import avito as avito_mod
from app.services.scrapers.avito import AvitoScraper
monkeypatch.setattr(
avito_mod.settings, "avito_proxy_rotate_url", "https://changeip.example/?proxy_key=x"
)
monkeypatch.setattr(avito_mod.settings, "avito_proxy_max_rotations", 1)
scraper = AvitoScraper()
fw = MagicMock()
fw.status_code = 200
fw.text = _FIREWALL_HTML
mock_session = AsyncMock()
mock_session.get = AsyncMock(return_value=fw) # всегда firewall
scraper._cffi = mock_session
with patch.object(AvitoScraper, "_rotate_ip", AsyncMock(return_value=True)) as rot:
with pytest.raises(AvitoBlockedError, match="firewall"):
await scraper.fetch_around(56.84, 60.60, 1000, pages=1)
assert rot.await_count == 1 # одна попытка ротации, потом сдаёмся

View file

@ -6,7 +6,7 @@ per-item JSON sortTimeStamp; у верхних карточек — из DOM dat
"""
import os
from datetime import UTC, date, datetime
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
@ -22,9 +22,13 @@ FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_sample.html"
_TS_LAZY = 1700000000000
_TS_TOP = 1700086400000
# #726: дата берётся в MSK (UTC+3), чтобы совпадать с тем, что Avito показывает на
# сайте (sortTimeStamp — UTC epoch, но отображается по Москве).
_MSK = timezone(timedelta(hours=3))
def _expected(ms: int) -> date:
return datetime.fromtimestamp(ms / 1000, tz=UTC).date()
return datetime.fromtimestamp(ms / 1000, tz=_MSK).date()
def test_sort_timestamp_map_built_from_json() -> None: