- 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:
parent
8f2ff10a1c
commit
147c36d448
4 changed files with 237 additions and 20 deletions
|
|
@ -124,5 +124,16 @@ class Settings(BaseSettings):
|
||||||
"n1.ru",
|
"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()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,17 @@ URL pattern (EKB):
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import UTC, date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode, urljoin
|
from urllib.parse import urlencode, urljoin
|
||||||
|
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from selectolax.parser import HTMLParser
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.services.scraper_settings import get_scraper_delay
|
from app.services.scraper_settings import get_scraper_delay
|
||||||
from app.services.scrapers.avito_exceptions import (
|
from app.services.scrapers.avito_exceptions import (
|
||||||
AvitoBlockedError,
|
AvitoBlockedError,
|
||||||
|
|
@ -151,6 +153,11 @@ def _parse_relative_date(s: str | None) -> date | None:
|
||||||
# Маппим item_id (= DOM `data-item-id`) → дата. `{0,1500}` ограничивает поиск телом
|
# Маппим item_id (= DOM `data-item-id`) → дата. `{0,1500}` ограничивает поиск телом
|
||||||
# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный
|
# одного item-объекта (id и sortTimeStamp лежат в нём; внутри ещё есть короткий вложенный
|
||||||
# `addressDetailed{…}`, поэтому не `[^{}]`). re.S — 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)
|
_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:
|
if item_id in out:
|
||||||
continue
|
continue
|
||||||
try:
|
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):
|
except (ValueError, OSError, OverflowError):
|
||||||
continue
|
continue
|
||||||
return out
|
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):
|
class AvitoScraper(BaseScraper):
|
||||||
"""Avito vtorichka parser. Источник = 'avito'.
|
"""Avito vtorichka parser. Источник = 'avito'.
|
||||||
|
|
||||||
|
|
@ -191,9 +210,14 @@ class AvitoScraper(BaseScraper):
|
||||||
|
|
||||||
async def __aenter__(self) -> AvitoScraper:
|
async def __aenter__(self) -> AvitoScraper:
|
||||||
await super().__aenter__()
|
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(
|
self._cffi = AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate="chrome120",
|
||||||
timeout=25,
|
timeout=25,
|
||||||
|
proxies=proxies,
|
||||||
headers={
|
headers={
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"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",
|
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
|
||||||
|
|
@ -212,6 +236,94 @@ class AvitoScraper(BaseScraper):
|
||||||
await self._cffi.close()
|
await self._cffi.close()
|
||||||
await super().__aexit__(*args)
|
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 ──────────────────────────────────────────────────────────────
|
# ── Public ──────────────────────────────────────────────────────────────
|
||||||
async def fetch_around(
|
async def fetch_around(
|
||||||
self,
|
self,
|
||||||
|
|
@ -242,28 +354,16 @@ class AvitoScraper(BaseScraper):
|
||||||
for page in range(1, pages + 1):
|
for page in range(1, pages + 1):
|
||||||
url = self._build_web_url(lat, lon, radius_km, page=page)
|
url = self._build_web_url(lat, lon, radius_km, page=page)
|
||||||
try:
|
try:
|
||||||
assert self._cffi is not None
|
html = await self._fetch_serp_html(url, page)
|
||||||
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
|
|
||||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("avito HTML page=%d fetch failed for %s", page, url)
|
logger.exception("avito HTML page=%d fetch failed for %s", page, url)
|
||||||
break
|
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 not lots:
|
||||||
if page == 1:
|
if page == 1:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -309,3 +309,105 @@ async def test_city_sweep_marks_banned_on_block() -> None:
|
||||||
assert mock_runs.mark_banned.called
|
assert mock_runs.mark_banned.called
|
||||||
assert not mock_runs.mark_done.called
|
assert not mock_runs.mark_done.called
|
||||||
assert result.anchors_done == 1
|
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
|
import os
|
||||||
from datetime import UTC, date, datetime
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
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_LAZY = 1700000000000
|
||||||
_TS_TOP = 1700086400000
|
_TS_TOP = 1700086400000
|
||||||
|
|
||||||
|
# #726: дата берётся в MSK (UTC+3), чтобы совпадать с тем, что Avito показывает на
|
||||||
|
# сайте (sortTimeStamp — UTC epoch, но отображается по Москве).
|
||||||
|
_MSK = timezone(timedelta(hours=3))
|
||||||
|
|
||||||
|
|
||||||
def _expected(ms: int) -> date:
|
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:
|
def test_sort_timestamp_map_built_from_json() -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue