diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index d9327655..7c76cd54 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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() diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 0449118d..0e55533f 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -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:00–03:00 MSK, давали бы дату на день раньше). Fixed offset, без +# зависимости от системной tzdata в контейнере. #726. +_MSK = timezone(timedelta(hours=3)) _SORT_TS_RE = re.compile(r'"id":(?P\d{8,12})\b.{0,1500}?"sortTimeStamp":(?P\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 в ), а НЕ 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( diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_anti_bot.py b/tradein-mvp/backend/tests/scrapers/test_avito_anti_bot.py index c18b2274..7d335cbe 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_anti_bot.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_anti_bot.py @@ -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 = ( + "" + '
' + '' + "2-к. квартира, 54,3 м², 5/9 эт." + "" + '' + "
" + "" +) + +# Firewall-интерстициал Avito (HTTP 200, не 403) — IP-блок datacenter. +_FIREWALL_HTML = ( + "Доступ ограничен: проблема с IP" + '
...
' +) + + +@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 # одна попытка ротации, потом сдаёмся diff --git a/tradein-mvp/backend/tests/test_avito_serp_date.py b/tradein-mvp/backend/tests/test_avito_serp_date.py index b370814b..870541d4 100644 --- a/tradein-mvp/backend/tests/test_avito_serp_date.py +++ b/tradein-mvp/backend/tests/test_avito_serp_date.py @@ -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: