Merge pull request 'feat(scrapers): cian SERP на BrowserFetcher (camoufox), снят code-side rotate (#1806)' (#1810) from feat/cian-serp-browser into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Reviewed-on: #1810
This commit is contained in:
commit
65cec74cca
3 changed files with 144 additions and 92 deletions
|
|
@ -5,9 +5,12 @@
|
||||||
- URL pattern: https://ekb.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2
|
- URL pattern: https://ekb.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2
|
||||||
ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema_Cian_SERP_Inventory sec 13).
|
ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema_Cian_SERP_Inventory sec 13).
|
||||||
|
|
||||||
ВАЖНО: Циан блокирует httpx по TLS fingerprint → "Обнаружен подозрительный трафик" (403).
|
ВАЖНО: Циан блокирует httpx/curl по TLS+fingerprint и сыплет Google reCAPTCHA на
|
||||||
Используем curl_cffi с impersonate='chrome120' — это libcurl-impersonate под капотом,
|
датацентр-IP. Транспорт SERP переведён на BrowserFetcher (camoufox real-browser
|
||||||
который воспроизводит TLS ClientHello как настоящий Chrome.
|
fingerprint через tradein-browser сервис, #1806 / epic #883 Phase 2). Браузер
|
||||||
|
отдаёт полный SSR-HTML с Redux initialState — парсинг state не меняется.
|
||||||
|
Мобильный прокси (ha.mobileproxy.space) применяется server-side в браузер-контейнере
|
||||||
|
и ротирует IP сам, поэтому code-side _rotate_ip (changeip) больше не нужен.
|
||||||
|
|
||||||
НЕ используем anchor jitter: offer.geo.coordinates.{lat,lng} — точные координаты
|
НЕ используем anchor jitter: offer.geo.coordinates.{lat,lng} — точные координаты
|
||||||
прямо из SERP state. Jitter запрещён per implementation plan.
|
прямо из SERP state. Jitter запрещён per implementation plan.
|
||||||
|
|
@ -16,7 +19,6 @@ Exhaustive load (fetch_all_secondary):
|
||||||
- Cian SERP — регион-wide (не geo-bbox). Anchor-loop в существующем city-sweep
|
- Cian SERP — регион-wide (не geo-bbox). Anchor-loop в существующем city-sweep
|
||||||
избыточен для Cian (все anchor'ы дают одну выдачу). Полный сбор = ОДИН проход
|
избыточен для Cian (все anchor'ы дают одну выдачу). Полный сбор = ОДИН проход
|
||||||
с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное деление диапазона).
|
с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное деление диапазона).
|
||||||
- IP-ротация через _rotate_ip() (changeip, зеркало avito.py).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -32,11 +34,11 @@ from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from curl_cffi.requests import AsyncSession
|
|
||||||
|
|
||||||
from app.core.config import settings
|
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.base import BaseScraper, ScrapedLot
|
from app.services.scrapers.base import BaseScraper, ScrapedLot
|
||||||
|
from app.services.scrapers.browser_fetcher import BrowserFetcher
|
||||||
from app.services.scrapers.cian_state_parser import extract_state
|
from app.services.scrapers.cian_state_parser import extract_state
|
||||||
from app.services.scrapers.price_brackets import get_price_seed_brackets
|
from app.services.scrapers.price_brackets import get_price_seed_brackets
|
||||||
from app.services.scrapers.repair_state_normalizer import (
|
from app.services.scrapers.repair_state_normalizer import (
|
||||||
|
|
@ -82,10 +84,14 @@ _DEFAULT_ROOMS_BUCKETS: list[tuple[int, ...]] = [
|
||||||
|
|
||||||
|
|
||||||
class CianScraper(BaseScraper):
|
class CianScraper(BaseScraper):
|
||||||
"""Cian SERP scraper. Использует curl_cffi для обхода TLS fingerprint.
|
"""Cian SERP scraper. Тянет SSR-HTML через BrowserFetcher (camoufox).
|
||||||
|
|
||||||
Извлекает 137 полей на offer из Redux initialState через cian_state_parser.
|
Извлекает 137 полей на offer из Redux initialState через cian_state_parser.
|
||||||
Координаты точные — НЕТ anchor jitter (offer.geo.coordinates).
|
Координаты точные — НЕТ anchor jitter (offer.geo.coordinates).
|
||||||
|
|
||||||
|
Транспорт: одна BrowserFetcher(source="cian") сессия открывается в __aenter__
|
||||||
|
и переиспользуется на всех page-fetch'ах (#1806). camoufox-fingerprint + мобильный
|
||||||
|
прокси server-side обходят Cian reCAPTCHA; IP ротирует сам прокси.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "cian"
|
name = "cian"
|
||||||
|
|
@ -97,79 +103,34 @@ class CianScraper(BaseScraper):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.request_delay_sec = get_scraper_delay(self.name)
|
self.request_delay_sec = get_scraper_delay(self.name)
|
||||||
self._cffi: AsyncSession | None = None
|
self._browser: BrowserFetcher | None = None
|
||||||
|
|
||||||
async def __aenter__(self) -> CianScraper:
|
async def __aenter__(self) -> CianScraper:
|
||||||
await super().__aenter__()
|
await super().__aenter__()
|
||||||
# curl_cffi session с Chrome 120 TLS fingerprint — КРИТИЧЕСКИ ВАЖНО для Cian.
|
# Одна BrowserFetcher(source="cian") сессия на весь scrape — camoufox real-browser
|
||||||
# proxies: mobile-proxy egress (#806) — аналогично Avito (#623). Без прокси
|
# fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на
|
||||||
# datacenter-IP блокируется Cian (403/captcha) несмотря на валидные cookies.
|
# отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому
|
||||||
# Пусто (SCRAPER_PROXY_URL/AVITO_PROXY_URL не заданы) → прямое подключение (dev).
|
# code-side warm-up cookies и changeip-ротация больше не нужны.
|
||||||
_proxy_url = settings.cian_proxy_url
|
self._browser = BrowserFetcher(source="cian")
|
||||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
await self._browser.__aenter__()
|
||||||
self._cffi = AsyncSession(
|
|
||||||
impersonate="chrome120",
|
|
||||||
timeout=30,
|
|
||||||
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",
|
|
||||||
"Cache-Control": "max-age=0",
|
|
||||||
"Sec-Fetch-Dest": "document",
|
|
||||||
"Sec-Fetch-Mode": "navigate",
|
|
||||||
"Sec-Fetch-Site": "none",
|
|
||||||
"Sec-Fetch-User": "?1",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Warm-up: посетить homepage чтобы получить сессионные cookies (_CIAN_GK/_yasc)
|
|
||||||
# и не выглядеть как наглый бот (первый запрос сразу на /cat.php подозрителен).
|
|
||||||
# Failure не критична — SERP-запросы продолжатся в любом случае.
|
|
||||||
try:
|
|
||||||
_warm = await self._cffi.get(f"{self.base_url}/")
|
|
||||||
logger.info(
|
|
||||||
"cian warm-up GET / → HTTP %d (cookies=%d)",
|
|
||||||
_warm.status_code,
|
|
||||||
len(list(self._cffi.cookies.jar)),
|
|
||||||
)
|
|
||||||
# Последующие SERP-запросы выглядят как in-site navigation (Referer = /)
|
|
||||||
self._cffi.headers.update(
|
|
||||||
{
|
|
||||||
"Referer": f"{self.base_url}/",
|
|
||||||
"Sec-Fetch-Site": "same-origin",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("cian warm-up GET / failed — continuing without seed cookies")
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, *args: Any) -> None:
|
async def __aexit__(self, *args: Any) -> None:
|
||||||
if self._cffi is not None:
|
if self._browser is not None:
|
||||||
await self._cffi.close()
|
await self._browser.__aexit__(*args)
|
||||||
|
self._browser = None
|
||||||
await super().__aexit__(*args)
|
await super().__aexit__(*args)
|
||||||
|
|
||||||
# ── Anti-block: IP rotation (зеркало avito.py _rotate_ip) ─────────────────
|
# ── Anti-block: IP rotation ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def _rotate_ip(self) -> bool:
|
async def _rotate_ip(self) -> bool:
|
||||||
"""Сменить мобильный IP через changeip-ссылку mobileproxy.
|
"""No-op после миграции на BrowserFetcher (#1806).
|
||||||
|
|
||||||
Дёргается напрямую (без прокси) — это API провайдера, не Cian. Ждём ~9с:
|
Мобильный прокси в браузер-контейнере ротирует IP сам, code-side changeip
|
||||||
мобильному модему нужно время поднять новый IP. Returns True при успехе.
|
больше не нужен. Метод сохранён (returns False) для обратной совместимости
|
||||||
Зеркало AvitoScraper._rotate_ip (#623).
|
с вызовом в _walk_price_range и тестами, которые его мокают.
|
||||||
"""
|
"""
|
||||||
rotate_url = settings.cian_proxy_rotate_url or settings.avito_proxy_rotate_url
|
return False
|
||||||
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("cian proxy: IP rotated via changeip")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
logger.warning("cian proxy: IP rotation failed", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def fetch_around(
|
async def fetch_around(
|
||||||
self,
|
self,
|
||||||
|
|
@ -189,17 +150,13 @@ class CianScraper(BaseScraper):
|
||||||
"""
|
"""
|
||||||
url = self._build_url(rooms, page)
|
url = self._build_url(rooms, page)
|
||||||
try:
|
try:
|
||||||
assert self._cffi is not None
|
assert self._browser is not None
|
||||||
response = await self._cffi.get(url)
|
html = await self._browser.fetch(url)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("cian curl_cffi fetch failed for url=%s", url)
|
logger.exception("cian BrowserFetcher fetch failed for url=%s", url)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if response.status_code != 200:
|
lots = self._parse_serp_html(html)
|
||||||
logger.warning("cian returned HTTP %d for url=%s", response.status_code, url)
|
|
||||||
return []
|
|
||||||
|
|
||||||
lots = self._parse_serp_html(response.text)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"cian: %d lots fetched rooms=%s page=%d url=%s",
|
"cian: %d lots fetched rooms=%s page=%d url=%s",
|
||||||
len(lots),
|
len(lots),
|
||||||
|
|
@ -299,18 +256,14 @@ class CianScraper(BaseScraper):
|
||||||
min_price: int | None,
|
min_price: int | None,
|
||||||
max_price: int | None,
|
max_price: int | None,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""GET одной SERP-страницы, возвращает HTML или None при ошибке."""
|
"""GET одной SERP-страницы через BrowserFetcher, возвращает HTML или None при ошибке."""
|
||||||
assert self._cffi is not None
|
assert self._browser is not None
|
||||||
url = self._build_url(rooms, page, min_price, max_price)
|
url = self._build_url(rooms, page, min_price, max_price)
|
||||||
try:
|
try:
|
||||||
resp = await self._cffi.get(url)
|
return await self._browser.fetch(url)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("cian: GET failed url=%s", url)
|
logger.exception("cian: BrowserFetcher fetch failed url=%s", url)
|
||||||
return None
|
return None
|
||||||
if resp.status_code != 200:
|
|
||||||
logger.warning("cian: HTTP %d for url=%s", resp.status_code, url)
|
|
||||||
return None
|
|
||||||
return resp.text
|
|
||||||
|
|
||||||
async def fetch_all_secondary(
|
async def fetch_all_secondary(
|
||||||
self,
|
self,
|
||||||
|
|
@ -446,21 +399,22 @@ class CianScraper(BaseScraper):
|
||||||
if html is not None:
|
if html is not None:
|
||||||
total = self._extract_total_offers(html)
|
total = self._extract_total_offers(html)
|
||||||
|
|
||||||
# Ретрай на captcha/ошибку: rotate IP + 1 retry
|
# Ретрай на captcha/ошибку: 1 повторный fetch. Мобильный прокси в браузер-
|
||||||
|
# контейнере ротирует IP сам (#1806), поэтому отдельный changeip-вызов
|
||||||
|
# (_rotate_ip) больше не нужен — просто повторяем запрос на чистом IP.
|
||||||
if total is None:
|
if total is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cian: totalOffers=None for %s [%d, %s] depth=%d — rotating IP + retry",
|
"cian: totalOffers=None for %s [%d, %s] depth=%d — retry (proxy auto-rotates IP)",
|
||||||
room_label,
|
room_label,
|
||||||
lo,
|
lo,
|
||||||
_hi_repr,
|
_hi_repr,
|
||||||
_depth,
|
_depth,
|
||||||
)
|
)
|
||||||
rotated = await self._rotate_ip()
|
await self._rotate_ip() # no-op (back-compat); прокси ротирует IP сам
|
||||||
if rotated:
|
html = await self._fetch_page_html(rooms, 1, _lo_param, hi)
|
||||||
html = await self._fetch_page_html(rooms, 1, _lo_param, hi)
|
await self.sleep_between_requests()
|
||||||
await self.sleep_between_requests()
|
if html is not None:
|
||||||
if html is not None:
|
total = self._extract_total_offers(html)
|
||||||
total = self._extract_total_offers(html)
|
|
||||||
|
|
||||||
if total is None:
|
if total is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ def scraper() -> CianScraper:
|
||||||
s.name = "cian"
|
s.name = "cian"
|
||||||
s.base_url = "https://ekb.cian.ru"
|
s.base_url = "https://ekb.cian.ru"
|
||||||
s.request_delay_sec = 0.0
|
s.request_delay_sec = 0.0
|
||||||
s._cffi = MagicMock() # не None → assert self._cffi is not None пройдёт
|
s._browser = MagicMock() # не None → assert self._browser is not None пройдёт
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Fixture формирует минимальный JS push() conforming to _cianC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -454,6 +455,103 @@ def test_cian_serp_no_coords_does_not_crash():
|
||||||
assert lots[0].lon is None
|
assert lots[0].lon is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transport: BrowserFetcher (camoufox) миграция (#1806) ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cian_aenter_opens_browser_fetcher() -> None:
|
||||||
|
"""__aenter__ открывает ОДНУ BrowserFetcher(source="cian") сессию (camoufox)."""
|
||||||
|
mock_browser = MagicMock()
|
||||||
|
mock_browser.__aenter__ = AsyncMock(return_value=mock_browser)
|
||||||
|
mock_browser.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _fake_browser(*args, **kwargs):
|
||||||
|
captured["source"] = kwargs.get("source") if kwargs else (args[0] if args else None)
|
||||||
|
return mock_browser
|
||||||
|
|
||||||
|
scraper = CianScraper()
|
||||||
|
with patch("app.services.scrapers.cian.BrowserFetcher", _fake_browser):
|
||||||
|
await scraper.__aenter__()
|
||||||
|
|
||||||
|
mock_browser.__aenter__.assert_awaited_once()
|
||||||
|
assert scraper._browser is mock_browser
|
||||||
|
assert captured["source"] == "cian"
|
||||||
|
|
||||||
|
await scraper.__aexit__(None, None, None)
|
||||||
|
mock_browser.__aexit__.assert_awaited_once()
|
||||||
|
assert scraper._browser is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cian_fetch_page_html_uses_browser_fetcher() -> None:
|
||||||
|
"""_fetch_page_html тянет HTML через BrowserFetcher.fetch, не через curl_cffi."""
|
||||||
|
scraper = CianScraper.__new__(CianScraper)
|
||||||
|
scraper.name = "cian"
|
||||||
|
scraper.base_url = "https://ekb.cian.ru"
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
fetched_urls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_fetch(url: str) -> str:
|
||||||
|
fetched_urls.append(url)
|
||||||
|
return "<html>serp-page</html>"
|
||||||
|
|
||||||
|
scraper._browser = MagicMock()
|
||||||
|
scraper._browser.fetch = AsyncMock(side_effect=fake_fetch)
|
||||||
|
|
||||||
|
html = await scraper._fetch_page_html((2,), 1, None, None)
|
||||||
|
|
||||||
|
assert html == "<html>serp-page</html>"
|
||||||
|
assert len(fetched_urls) == 1
|
||||||
|
assert "cat.php" in fetched_urls[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cian_fetch_page_html_returns_none_on_browser_error() -> None:
|
||||||
|
"""Если BrowserFetcher.fetch падает — _fetch_page_html возвращает None (не краш)."""
|
||||||
|
scraper = CianScraper.__new__(CianScraper)
|
||||||
|
scraper.name = "cian"
|
||||||
|
scraper.base_url = "https://ekb.cian.ru"
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
|
||||||
|
scraper._browser = MagicMock()
|
||||||
|
scraper._browser.fetch = AsyncMock(side_effect=RuntimeError("browser down"))
|
||||||
|
|
||||||
|
html = await scraper._fetch_page_html((1,), 2, 1_000_000, 5_000_000)
|
||||||
|
assert html is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cian_fetch_around_parses_browser_html() -> None:
|
||||||
|
"""fetch_around парсит лоты из SSR-HTML, отданного BrowserFetcher."""
|
||||||
|
offer = _make_full_offer(cian_id=777)
|
||||||
|
serp_html = _make_cian_serp_html([offer])
|
||||||
|
|
||||||
|
scraper = CianScraper.__new__(CianScraper)
|
||||||
|
scraper.name = "cian"
|
||||||
|
scraper.base_url = "https://ekb.cian.ru"
|
||||||
|
scraper.request_delay_sec = 0.0
|
||||||
|
scraper._browser = MagicMock()
|
||||||
|
scraper._browser.fetch = AsyncMock(return_value=serp_html)
|
||||||
|
|
||||||
|
with patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock):
|
||||||
|
lots = await scraper.fetch_around(56.84, 60.60, rooms=(2,), page=1)
|
||||||
|
|
||||||
|
assert len(lots) == 1
|
||||||
|
assert lots[0].source_id == "777"
|
||||||
|
scraper._browser.fetch.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cian_rotate_ip_is_noop() -> None:
|
||||||
|
"""_rotate_ip после миграции — no-op, всегда False (прокси ротирует IP сам)."""
|
||||||
|
scraper = CianScraper.__new__(CianScraper)
|
||||||
|
result = await scraper._rotate_ip()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
# ── Address formatter tests ──────────────────────────────────────────────────
|
# ── Address formatter tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue