feat(tradein): Avito mobile proxy egress + firewall detect + MSK date (#623, #726) (#788)
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 24s
Deploy Trade-In / deploy (push) Successful in 33s
Deploy Trade-In / build-backend (push) Successful in 39s
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 24s
Deploy Trade-In / deploy (push) Successful in 33s
Deploy Trade-In / build-backend (push) Successful in 39s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
a45d4b3cf4
commit
96027357b5
4 changed files with 237 additions and 20 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
|
|
|
|||
|
|
@ -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 # одна попытка ротации, потом сдаёмся
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue