fix(tradein/scraper-kit): актуализация отпечатка Авито — impersonate, Sec-Fetch-Site, referer, проверка антибот-кук #3038
16 changed files with 376 additions and 41 deletions
|
|
@ -54,7 +54,7 @@ from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_excep
|
||||||
# {"http": url, "https": url} if url else None (http_proxies) в
|
# {"http": url, "https": url} if url else None (http_proxies) в
|
||||||
# providers/_base.py (#2358 Foundation) — реюзаем вместо копии, разрывая
|
# providers/_base.py (#2358 Foundation) — реюзаем вместо копии, разрывая
|
||||||
# зависимость от scrape_pipeline.py перед его удалением.
|
# зависимость от scrape_pipeline.py перед его удалением.
|
||||||
from scraper_kit.providers._base import DOCUMENT_HEADERS, http_proxies
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS, http_proxies
|
||||||
from scraper_kit.providers.avito.detail import (
|
from scraper_kit.providers.avito.detail import (
|
||||||
_AVITO_WARM_SEARCH_URL,
|
_AVITO_WARM_SEARCH_URL,
|
||||||
build_warmed_session,
|
build_warmed_session,
|
||||||
|
|
@ -285,7 +285,7 @@ async def run_avito_detail_backfill(
|
||||||
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
|
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
|
||||||
own_session = True
|
own_session = True
|
||||||
session = AsyncSession(
|
session = AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=DOCUMENT_HEADERS,
|
headers=DOCUMENT_HEADERS,
|
||||||
proxies=http_proxies(resolve_proxy_url(db, "avito")),
|
proxies=http_proxies(resolve_proxy_url(db, "avito")),
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ dependencies = [
|
||||||
"lxml>=5.0.0", # стриминговый iterparse для многогигабайтных ГАР XML (#143)
|
"lxml>=5.0.0", # стриминговый iterparse для многогигабайтных ГАР XML (#143)
|
||||||
"segno>=1.6.0", # QR-код для PDF shareable URL
|
"segno>=1.6.0", # QR-код для PDF shareable URL
|
||||||
"matplotlib>=3.9.0", # price-range chart (SVG) для PDF отчёта Trade-In
|
"matplotlib>=3.9.0", # price-range chart (SVG) для PDF отчёта Trade-In
|
||||||
"curl-cffi>=0.7.0", # impersonate=chrome120 для Cian/Avito (TLS fingerprint)
|
"curl-cffi>=0.15.0", # impersonate=<profile> для Cian/Avito (TLS fingerprint).
|
||||||
|
# Пол 0.15.0, а не 0.7.0: DEFAULT_IMPERSONATE=chrome146, а этот
|
||||||
|
# профиль появился позже 0.7.0 — установка по нижней границе
|
||||||
|
# роняла бы КАЖДЫЙ запрос скраппера на невалидном impersonate.
|
||||||
"python-multipart>=0.0.9", # FastAPI UploadFile — загрузка фото квартиры (#394)
|
"python-multipart>=0.0.9", # FastAPI UploadFile — загрузка фото квартиры (#394)
|
||||||
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
|
||||||
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""#3034: прогрев (`warm_up_session`/`research_in_session`) проверяет антибот-cookies.
|
||||||
|
|
||||||
|
Баг: обе функции считали прогрев успешным по HTTP-статусу и отсутствию
|
||||||
|
firewall-маркеров, но НИКОГДА не смотрели, реально ли осели антибот-cookies
|
||||||
|
(`__zzatw-*`/`cfidsw-*`) — сессия могла выглядеть «прогретой» и идти в
|
||||||
|
detail-батч, который затем сжигал прокси на обречённых 403.
|
||||||
|
|
||||||
|
Этот файл доказывает три вещи:
|
||||||
|
1. Cookies есть → обе функции возвращают True (не меняли поведение happy-path).
|
||||||
|
2. Страница отдалась 200/не-firewall, но cookies НЕТ → `AvitoWarmupCookiesMissingError`
|
||||||
|
(наследник `AvitoBlockedError` — существующие `except (AvitoBlockedError,
|
||||||
|
AvitoRateLimitedError)` в pipeline/backfill ловят его без изменений).
|
||||||
|
3. Явный firewall/не-200 — поведение НЕ поменялось: тихий `return False`
|
||||||
|
(best-effort деградация), как было до фикса.
|
||||||
|
|
||||||
|
Плюс: GET на прогревочный search-URL шлётся с `Sec-Fetch-Site: cross-site` (через
|
||||||
|
`referer_headers()`) — до фикса session-level `DOCUMENT_HEADERS` default ("none")
|
||||||
|
молча переживал добавление Referer per-request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from scraper_kit.avito_exceptions import AvitoWarmupCookiesMissingError
|
||||||
|
from scraper_kit.providers.avito.detail import (
|
||||||
|
_AVITO_WARM_YANDEX_REFERER,
|
||||||
|
research_in_session,
|
||||||
|
warm_up_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
# warm_up_session спит random.uniform(2.0, 3.5) между yandex- и avito-GET (органический
|
||||||
|
# темп для anti-bot) — no-op в тестах, иначе сьют идёт секундами вместо миллисекунд.
|
||||||
|
_SLEEP = "scraper_kit.providers.avito.detail.asyncio.sleep"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_session(
|
||||||
|
status_code: int, text: str = "", cookie_names: tuple[str, ...] = ()
|
||||||
|
) -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.text = text
|
||||||
|
session = MagicMock()
|
||||||
|
session.get = AsyncMock(return_value=resp)
|
||||||
|
session.cookies = {name: "1" for name in cookie_names}
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_sleep():
|
||||||
|
with patch(_SLEEP, new_callable=AsyncMock):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# ── warm_up_session ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warm_up_session_ok_with_antibot_cookies_returns_true() -> None:
|
||||||
|
session = _fake_session(200, cookie_names=("__zzatw-avito", "sx"))
|
||||||
|
assert await warm_up_session(session) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warm_up_session_ok_without_antibot_cookies_raises() -> None:
|
||||||
|
"""Ровно #3034: страница отдалась 200/не-firewall, но никакой антибот-cookie
|
||||||
|
(только "обычные" сессионные) — раньше это тихо возвращало True."""
|
||||||
|
session = _fake_session(200, cookie_names=("f", "ft", "luri"))
|
||||||
|
with pytest.raises(AvitoWarmupCookiesMissingError):
|
||||||
|
await warm_up_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warm_up_session_no_cookies_at_all_raises() -> None:
|
||||||
|
session = _fake_session(200, cookie_names=())
|
||||||
|
with pytest.raises(AvitoWarmupCookiesMissingError):
|
||||||
|
await warm_up_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warm_up_session_blocked_page_returns_false_not_raise() -> None:
|
||||||
|
"""Явный блок площадки (firewall) остаётся best-effort False, не raise —
|
||||||
|
неизменное поведение, отдельное от cookie-проверки."""
|
||||||
|
session = _fake_session(200, text="доступ ограничен")
|
||||||
|
assert await warm_up_session(session) is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warm_up_session_non_200_returns_false_not_raise() -> None:
|
||||||
|
session = _fake_session(403)
|
||||||
|
assert await warm_up_session(session) is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warm_up_session_search_get_uses_cross_site_referer_headers() -> None:
|
||||||
|
session = _fake_session(200, cookie_names=("__zzatw-avito",))
|
||||||
|
await warm_up_session(session)
|
||||||
|
|
||||||
|
# Второй call — GET на _AVITO_WARM_SEARCH_URL (первый — GET на yandex, best-effort).
|
||||||
|
_, kwargs = session.get.call_args_list[-1]
|
||||||
|
assert kwargs["headers"] == {
|
||||||
|
"Referer": _AVITO_WARM_YANDEX_REFERER,
|
||||||
|
"Sec-Fetch-Site": "cross-site",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── research_in_session ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def test_research_in_session_ok_with_antibot_cookies_returns_true() -> None:
|
||||||
|
session = _fake_session(200, cookie_names=("cfidsw-avito",))
|
||||||
|
assert await research_in_session(session) is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_research_in_session_ok_without_antibot_cookies_raises() -> None:
|
||||||
|
session = _fake_session(200, cookie_names=("uxs_uid",))
|
||||||
|
with pytest.raises(AvitoWarmupCookiesMissingError):
|
||||||
|
await research_in_session(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_research_in_session_blocked_page_returns_false_not_raise() -> None:
|
||||||
|
session = _fake_session(429)
|
||||||
|
assert await research_in_session(session) is False
|
||||||
67
tradein-mvp/backend/tests/test_impersonate_single_source.py
Normal file
67
tradein-mvp/backend/tests/test_impersonate_single_source.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Единственный источник правды для curl_cffi impersonate-профиля (#3034).
|
||||||
|
|
||||||
|
До этого фикса `impersonate="chrome120"` был захардкожен литералом в 9+ местах
|
||||||
|
scraper_kit (`providers/avito/{serp,detail,imv,houses}.py`,
|
||||||
|
`orchestration/pipeline.py` x4, `providers/cian/valuation.py`,
|
||||||
|
`providers/yandex/valuation.py`) — обновить TLS-профиль значило найти и
|
||||||
|
поправить их все вручную, и один пропущенный литерал молча гонял бы устаревший
|
||||||
|
профиль. Теперь единственный источник — `DEFAULT_IMPERSONATE`
|
||||||
|
(`providers/_base.py`); любой caller обязан читать константу.
|
||||||
|
|
||||||
|
ПОЧЕМУ ГРЕП, А НЕ AST. Инвариант — «строка `chrome120` не встречается нигде в
|
||||||
|
дереве» (ни в коде, ни в docstring/комментарии) — буквально то, что нужно
|
||||||
|
поймать: сменится профиль — не должно остаться ни одного места, которое надо
|
||||||
|
будет вспомнить и найти руками. AST увидел бы только строковые константы в
|
||||||
|
выражениях (kwarg-значения, docstring), но не поймал бы упоминание в
|
||||||
|
`#`-комментарии — а именно в комментарии литерал незаметнее всего переживёт
|
||||||
|
следующий рефактор.
|
||||||
|
|
||||||
|
Область: `packages/scraper-kit/src/scraper_kit/` — пакет, где живёт
|
||||||
|
`DEFAULT_IMPERSONATE` и все providers. Legacy-дубликаты вне kit (например
|
||||||
|
`app/services/cian_session.py`, ещё не мигрированный на kit, #2361 F4a) сюда
|
||||||
|
намеренно не входят — отдельный, более крупный периметр вне scope #3034.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_KIT_SRC = _BACKEND_ROOT.parent / "packages" / "scraper-kit" / "src" / "scraper_kit"
|
||||||
|
|
||||||
|
_BANNED_LITERAL = "chrome120"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_area_exists() -> None:
|
||||||
|
"""Область сканирования жива — иначе сторож зелен вхолостую (путь съехал)."""
|
||||||
|
assert _KIT_SRC.is_dir(), f"область сканирования съехала: {_KIT_SRC}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_detector_actually_detects(tmp_path: Path) -> None:
|
||||||
|
"""Сторож обязан уметь краснеть на литерале — иначе он зелен вхолостую."""
|
||||||
|
probe = tmp_path / "probe.py"
|
||||||
|
probe.write_text('impersonate = "chrome120"\n', encoding="utf-8")
|
||||||
|
assert _BANNED_LITERAL in probe.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
clean = tmp_path / "clean.py"
|
||||||
|
clean.write_text(
|
||||||
|
"from scraper_kit.providers._base import DEFAULT_IMPERSONATE\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert _BANNED_LITERAL not in clean.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_impersonate_is_the_only_chrome_profile_literal() -> None:
|
||||||
|
"""Ни один файл scraper_kit не содержит строку "chrome120" — ни в коде, ни в
|
||||||
|
docstring/комментарии. Единственное разрешённое место для конкретного номера
|
||||||
|
профиля — значение `DEFAULT_IMPERSONATE` в `providers/_base.py`.
|
||||||
|
"""
|
||||||
|
offenders = [
|
||||||
|
str(path.relative_to(_KIT_SRC))
|
||||||
|
for path in sorted(_KIT_SRC.rglob("*.py"))
|
||||||
|
if _BANNED_LITERAL in path.read_text(encoding="utf-8")
|
||||||
|
]
|
||||||
|
assert offenders == [], (
|
||||||
|
f"{offenders}: литерал {_BANNED_LITERAL!r} обходит DEFAULT_IMPERSONATE "
|
||||||
|
"(providers/_base.py) — единственный источник правды для impersonate-"
|
||||||
|
"профиля. Прочитай константу вместо хардкода строки."
|
||||||
|
)
|
||||||
|
|
@ -24,6 +24,7 @@ from scraper_kit.providers._base import (
|
||||||
build_curl_cffi_session,
|
build_curl_cffi_session,
|
||||||
build_document_session,
|
build_document_session,
|
||||||
http_proxies,
|
http_proxies,
|
||||||
|
referer_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -119,6 +120,37 @@ async def test_build_document_session_custom_timeout_and_proxy() -> None:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sec-Fetch-Site (#3034) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_headers_default_sec_fetch_site_is_none() -> None:
|
||||||
|
"""Без Referer на конкретном запросе — "none" остаётся дефолтом session-level
|
||||||
|
заголовков (запрос без Referer = «адрес введён руками», это ЗАДУМАННОЕ значение
|
||||||
|
для caller'ов, которые никогда не добавляют Referer, например SERP-фетч)."""
|
||||||
|
assert DOCUMENT_HEADERS["Sec-Fetch-Site"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_referer_headers_sets_cross_site() -> None:
|
||||||
|
"""referer_headers() — per-request override для caller'ов с Referer с ДРУГОГО
|
||||||
|
домена (#3034): должен явно менять Sec-Fetch-Site на "cross-site", а не оставлять
|
||||||
|
его наследоваться от session-level DOCUMENT_HEADERS ("none")."""
|
||||||
|
headers = referer_headers("https://ya.ru/")
|
||||||
|
assert headers == {"Referer": "https://ya.ru/", "Sec-Fetch-Site": "cross-site"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_referer_headers_overrides_session_default_when_merged() -> None:
|
||||||
|
"""Прямая проверка сценария #3034: сессия построена с DOCUMENT_HEADERS
|
||||||
|
(Sec-Fetch-Site="none"), per-request headers=referer_headers(...) должен
|
||||||
|
победить session-default при мёрже curl_cffi (per-request поверх session-level)."""
|
||||||
|
session = build_document_session(proxy_url=None)
|
||||||
|
try:
|
||||||
|
merged = dict(session.headers)
|
||||||
|
merged.update({k.lower(): v for k, v in referer_headers("https://ya.ru/").items()})
|
||||||
|
assert merged["sec-fetch-site"] == "cross-site"
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
# ── build_browser_fetcher ──────────────────────────────────────────────────────
|
# ── build_browser_fetcher ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ dependencies = [
|
||||||
"httpx[socks]>=0.27.0",
|
"httpx[socks]>=0.27.0",
|
||||||
"tenacity>=9.0.0",
|
"tenacity>=9.0.0",
|
||||||
"selectolax>=0.3.0",
|
"selectolax>=0.3.0",
|
||||||
"curl-cffi>=0.7.0",
|
"curl-cffi>=0.15.0", # пол задан профилем chrome146 из DEFAULT_IMPERSONATE, см. providers/_base.py
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
|
||||||
|
|
@ -60,5 +60,18 @@ class AvitoContentBlockedError(AvitoBlockedError):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AvitoWarmupCookiesMissingError(AvitoBlockedError):
|
||||||
|
"""Прогрев вернул HTTP 200 без firewall-маркеров, но антибот-cookies
|
||||||
|
(`__zzatw-*`/`cfidsw-*`) НЕ появились в сессии (#3034).
|
||||||
|
|
||||||
|
Тихий soft-block, не пойманный `_is_firewall_page`/`_is_detail_soft_block`: страница
|
||||||
|
отдалась, но сессия не помечена площадкой как прогретая — детейл-батч на ней сжигал
|
||||||
|
бы прокси на обречённых запросах (403 на каждой карточке). Наследует AvitoBlockedError,
|
||||||
|
чтобы существующие `except (AvitoBlockedError, AvitoRateLimitedError)` (ban_kind,
|
||||||
|
proxy-ротация, consecutive-block breaker в pipeline/backfill) отработали как на обычном
|
||||||
|
блоке площадки, без отдельного except-блока на call-сайтах.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class AvitoParseError(AvitoError):
|
class AvitoParseError(AvitoError):
|
||||||
"""Cannot parse Avito HTML structure (selector changes)."""
|
"""Cannot parse Avito HTML structure (selector changes)."""
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ from scraper_kit.orchestration import runs
|
||||||
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
|
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
|
||||||
# работать и когда тесты подменяют весь модуль runs двойником (#2686).
|
# работать и когда тесты подменяют весь модуль runs двойником (#2686).
|
||||||
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM, BAN_KIND_UNKNOWN
|
from scraper_kit.orchestration.runs import BAN_KIND_INFRA, BAN_KIND_PLATFORM, BAN_KIND_UNKNOWN
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||||||
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
from scraper_kit.providers.avito.detail import fetch_detail, save_detail_enrichment
|
||||||
from scraper_kit.providers.avito.houses import (
|
from scraper_kit.providers.avito.houses import (
|
||||||
fetch_house_catalog,
|
fetch_house_catalog,
|
||||||
|
|
@ -727,7 +728,7 @@ async def run_avito_pipeline(
|
||||||
else:
|
else:
|
||||||
own_session = shared_session is None
|
own_session = shared_session is None
|
||||||
session = shared_session or AsyncSession(
|
session = shared_session or AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=_CHROME_HEADERS,
|
headers=_CHROME_HEADERS,
|
||||||
proxies=_avito_proxies(config),
|
proxies=_avito_proxies(config),
|
||||||
|
|
@ -1228,7 +1229,7 @@ async def run_avito_city_sweep(
|
||||||
else:
|
else:
|
||||||
session = await stack.enter_async_context(
|
session = await stack.enter_async_context(
|
||||||
AsyncSession(
|
AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=_CHROME_HEADERS,
|
headers=_CHROME_HEADERS,
|
||||||
proxies=_avito_proxies(config),
|
proxies=_avito_proxies(config),
|
||||||
|
|
@ -1931,7 +1932,7 @@ async def run_avito_newbuilding_sweep(
|
||||||
else:
|
else:
|
||||||
session = await stack.enter_async_context(
|
session = await stack.enter_async_context(
|
||||||
AsyncSession(
|
AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=_CHROME_HEADERS,
|
headers=_CHROME_HEADERS,
|
||||||
proxies=_avito_proxies(config),
|
proxies=_avito_proxies(config),
|
||||||
|
|
@ -2349,7 +2350,7 @@ async def run_yandex_city_sweep(
|
||||||
_yandex_enrich_abort = 3 # abort address-enrich после N подряд
|
_yandex_enrich_abort = 3 # abort address-enrich после N подряд
|
||||||
|
|
||||||
async with AsyncSession(
|
async with AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
headers={"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8"},
|
headers={"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8"},
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
endpoint/use_pool технически невозможно, баг-класс закрыт структурно, а не
|
endpoint/use_pool технически невозможно, баг-класс закрыт структурно, а не
|
||||||
точечным патчем.
|
точечным патчем.
|
||||||
|
|
||||||
2. **curl_cffi `AsyncSession(impersonate="chrome120", proxies=..., headers=...)`
|
2. **curl_cffi `AsyncSession(impersonate=<profile>, proxies=..., headers=...)`
|
||||||
дублировался почти дословно** в `avito/serp.py::_build_cffi_session` (404-428),
|
дублировался почти дословно** в `avito/serp.py::_build_cffi_session` (404-428),
|
||||||
`avito/detail.py::_build_detail_session` (256-283, ИДЕНТИЧНЫЙ headers-dict),
|
`avito/detail.py::_build_detail_session` (256-283, ИДЕНТИЧНЫЙ headers-dict),
|
||||||
`avito/imv.py` (headers=_DOC_HEADERS, тот же dict третий раз), `cian/detail.py`
|
`avito/imv.py` (headers=_DOC_HEADERS, тот же dict третий раз), `cian/detail.py`
|
||||||
|
|
@ -61,12 +61,30 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# curl_cffi impersonate-профиль, используемый ВСЕМИ providers без исключения
|
# curl_cffi impersonate-профиль, используемый ВСЕМИ providers без исключения
|
||||||
# (TLS ClientHello настоящего Chrome — обход fingerprint-детекта).
|
# (TLS ClientHello настоящего Chrome — обход fingerprint-детекта).
|
||||||
DEFAULT_IMPERSONATE = "chrome120"
|
#
|
||||||
|
# Значение #3034: живой Chrome (замер 2026-08-21) — версия 151; в контейнере
|
||||||
|
# curl_cffi 0.15.0 старше и максимум доступного профиля — этот. Алиас "chrome"
|
||||||
|
# (без номера версии) НАРОЧНО не используем — он едет сам при апгрейде
|
||||||
|
# curl_cffi и меняет TLS ClientHello молча, без ревью и без корреляции с
|
||||||
|
# banned/failed в scrape_runs. Единственный источник правды: любое место,
|
||||||
|
# которому нужен impersonate-профиль, читает эту константу, а не хардкодит
|
||||||
|
# строку — см. backend/tests/test_impersonate_single_source.py.
|
||||||
|
DEFAULT_IMPERSONATE = "chrome146"
|
||||||
|
|
||||||
# "Document"-style browser headers для curl_cffi GET верхнеуровневых HTML-страниц
|
# "Document"-style browser headers для curl_cffi GET верхнеуровневых HTML-страниц
|
||||||
# (SERP/detail — НЕ XHR/JSON API-запросы, для них headers не нужны/другие).
|
# (SERP/detail — НЕ XHR/JSON API-запросы, для них headers не нужны/другие).
|
||||||
# Идентичен ранее продублированному `_DOC_HEADERS` (avito/imv.py) и inline-dict
|
# Идентичен ранее продублированному `_DOC_HEADERS` (avito/imv.py) и inline-dict
|
||||||
# в avito/serp.py::_build_cffi_session / avito/detail.py::_build_detail_session.
|
# в avito/serp.py::_build_cffi_session / avito/detail.py::_build_detail_session.
|
||||||
|
#
|
||||||
|
# Sec-Fetch-Site="none" — дефолт для запроса БЕЗ Referer (браузер трактует такой
|
||||||
|
# запрос как «адрес введён руками»/bookmark). Это ЗАДУМАННОЕ поведение здесь:
|
||||||
|
# сессия строится этим модулем один раз, а Referer (если он вообще есть у
|
||||||
|
# конкретного запроса) добавляется caller'ом позже, per-request. curl_cffi/httpx
|
||||||
|
# мёржат per-request `headers=` ПОВЕРХ этих session-level defaults — если
|
||||||
|
# caller добавляет `Referer` без соответствующей правки `Sec-Fetch-Site`, он молча
|
||||||
|
# остаётся "none" рядом с реальным Referer, чего настоящий Chrome не делает
|
||||||
|
# никогда (#3034). Такой caller обязан использовать `referer_headers()` ниже
|
||||||
|
# вместо голого `{"Referer": ...}`.
|
||||||
DOCUMENT_HEADERS: dict[str, str] = {
|
DOCUMENT_HEADERS: dict[str, str] = {
|
||||||
"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",
|
||||||
|
|
@ -79,6 +97,24 @@ DOCUMENT_HEADERS: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def referer_headers(referer: str) -> dict[str, str]:
|
||||||
|
"""Per-request header override для GET с известным на конкретном запросе Referer.
|
||||||
|
|
||||||
|
Используй ВМЕСТО голого `{"Referer": referer}` там, где сессия построена
|
||||||
|
через `build_document_session`/`DOCUMENT_HEADERS` (session default
|
||||||
|
`Sec-Fetch-Site: "none"`). curl_cffi мёржит per-request `headers=` поверх
|
||||||
|
session-level defaults по ключу — без явного override здесь `Sec-Fetch-Site`
|
||||||
|
молча наследуется как "none" даже когда Referer задан.
|
||||||
|
|
||||||
|
`Sec-Fetch-Site="cross-site"` — все текущие caller'ы этой функции шлют
|
||||||
|
Referer с ДРУГОГО домена (#3034: Avito warm-up идёт с ya.ru/yandex.ru на
|
||||||
|
avito.ru). Если появится caller с same-site Referer (переход внутри одного
|
||||||
|
домена), ему нужен отдельный `"same-origin"`/`"same-site"` override — эта
|
||||||
|
функция такой случай не покрывает намеренно, чтобы не гадать по URL.
|
||||||
|
"""
|
||||||
|
return {"Referer": referer, "Sec-Fetch-Site": "cross-site"}
|
||||||
|
|
||||||
|
|
||||||
def http_proxies(proxy_url: str | None) -> dict[str, str] | None:
|
def http_proxies(proxy_url: str | None) -> dict[str, str] | None:
|
||||||
"""curl_cffi-совместимый `proxies=` dict из одного url.
|
"""curl_cffi-совместимый `proxies=` dict из одного url.
|
||||||
|
|
||||||
|
|
@ -138,7 +174,7 @@ def build_document_session(
|
||||||
|
|
||||||
Это ровно та форма, что дублирована в `avito/serp.py::_build_cffi_session` и
|
Это ровно та форма, что дублирована в `avito/serp.py::_build_cffi_session` и
|
||||||
`avito/detail.py::_build_detail_session` (identical headers, timeout=25,
|
`avito/detail.py::_build_detail_session` (identical headers, timeout=25,
|
||||||
impersonate=chrome120) — единственная разница между сайтами вызова была
|
impersonate=DEFAULT_IMPERSONATE) — единственная разница между сайтами вызова была
|
||||||
источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но
|
источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но
|
||||||
прокидывалось разными путями).
|
прокидывалось разными путями).
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,11 @@ from scraper_kit.avito_exceptions import (
|
||||||
AvitoListingGoneError,
|
AvitoListingGoneError,
|
||||||
AvitoRateLimitedError,
|
AvitoRateLimitedError,
|
||||||
AvitoSidecarUnavailableError,
|
AvitoSidecarUnavailableError,
|
||||||
|
AvitoWarmupCookiesMissingError,
|
||||||
)
|
)
|
||||||
from scraper_kit.ceiling_height import plausible_ceiling_m
|
from scraper_kit.ceiling_height import plausible_ceiling_m
|
||||||
from scraper_kit.geo import is_within_oblast66_bbox
|
from scraper_kit.geo import is_within_oblast66_bbox
|
||||||
from scraper_kit.providers._base import build_document_session
|
from scraper_kit.providers._base import build_document_session, referer_headers
|
||||||
from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page
|
from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page
|
||||||
from scraper_kit.providers.avito.shared import RUS_MONTHS
|
from scraper_kit.providers.avito.shared import RUS_MONTHS
|
||||||
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
|
||||||
|
|
@ -57,7 +58,7 @@ logger = logging.getLogger(__name__)
|
||||||
AVITO_BASE = "https://www.avito.ru"
|
AVITO_BASE = "https://www.avito.ru"
|
||||||
|
|
||||||
# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET
|
# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET
|
||||||
# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая
|
# Avito EKB search-страница (Referer=ya.ru) сеет антибот-куки, после чего прогретая
|
||||||
# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22
|
# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22
|
||||||
# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы.
|
# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы.
|
||||||
_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote(
|
_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote(
|
||||||
|
|
@ -66,7 +67,13 @@ _AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q="
|
||||||
_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote(
|
_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote(
|
||||||
"купить квартиру екатеринбург авито"
|
"купить квартиру екатеринбург авито"
|
||||||
)
|
)
|
||||||
_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/"
|
# #3034: живой переход из яндексовой выдачи (замер 2026-08-21) даёт Referer с
|
||||||
|
# короткого домена ya.ru, не yandex.ru — используем то же значение, что реальный
|
||||||
|
# браузер. `ysclid` (клик-идентификатор, который Яндекс проставляет в реальном
|
||||||
|
# переходе) НАМЕРЕННО не добавляем в прогревочный URL/referer: значение выпускает
|
||||||
|
# Яндекс на своей стороне, подделанное — отличимый сигнал сильнее, чем его
|
||||||
|
# отсутствие.
|
||||||
|
_AVITO_WARM_YANDEX_REFERER = "https://ya.ru/"
|
||||||
|
|
||||||
# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса
|
# Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса
|
||||||
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
|
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
|
||||||
|
|
@ -258,8 +265,8 @@ class DetailEnrichment:
|
||||||
|
|
||||||
|
|
||||||
def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
|
def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
|
||||||
"""Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси
|
"""Создать curl_cffi-сессию для detail-fetch (impersonate=DEFAULT_IMPERSONATE, через
|
||||||
если задан config.scraper_proxy_url).
|
прокси если задан config.scraper_proxy_url).
|
||||||
|
|
||||||
Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать
|
Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать
|
||||||
эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403.
|
эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403.
|
||||||
|
|
@ -272,10 +279,35 @@ def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
|
||||||
return build_document_session(proxy_url=proxy_url, timeout=25)
|
return build_document_session(proxy_url=proxy_url, timeout=25)
|
||||||
|
|
||||||
|
|
||||||
|
# Антибот-cookies, которые Avito сеет прогретой curl_cffi-сессии (#3034). Снято с
|
||||||
|
# живого браузера 2026-08-21 (2 прогретые страницы) — полный набор шире
|
||||||
|
# (__ai_fp_uuid, uxs_uid, sx, f, ft, luri, domain_sid, acs_3), но эти два префикса
|
||||||
|
# специфичны именно антибот-семейству, а не сессионной аналитике. Набор может
|
||||||
|
# протухнуть, если площадка сменит анти-бот вендора/схему cookies — обнови по
|
||||||
|
# свежему замеру, если проверка начнёт ложно краснеть на живом трафике.
|
||||||
|
_AVITO_ANTIBOT_COOKIE_PREFIXES = ("__zzatw-", "cfidsw-")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_antibot_cookies(session: AsyncSession) -> bool:
|
||||||
|
"""True если хотя бы одна антибот-cookie осела в сессии после прогрева."""
|
||||||
|
try:
|
||||||
|
names = list(session.cookies.keys())
|
||||||
|
except Exception:
|
||||||
|
logger.debug("avito warm-up: cookie jar read failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
return any(name.startswith(_AVITO_ANTIBOT_COOKIE_PREFIXES) for name in names)
|
||||||
|
|
||||||
|
|
||||||
async def warm_up_session(session: AsyncSession) -> bool:
|
async def warm_up_session(session: AsyncSession) -> bool:
|
||||||
"""Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search
|
"""Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search
|
||||||
(Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
|
(Referer=ya.ru). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
|
||||||
без 403. Возвращает True если search-страница загрузилась (не firewall).
|
без 403. Возвращает True если search-страница загрузилась (не firewall) И
|
||||||
|
антибот-куки реально появились в сессии.
|
||||||
|
|
||||||
|
Раньше (#3034) страница могла отдать HTTP 200 без firewall-маркеров, но БЕЗ
|
||||||
|
антибот-cookies — прогрев считался успешным, а detail-батч на такой
|
||||||
|
«прогретой» сессии сжигал прокси на обречённых 403. Теперь такой случай
|
||||||
|
поднимает AvitoWarmupCookiesMissingError вместо тихого `return True`.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort
|
await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort
|
||||||
|
|
@ -284,7 +316,7 @@ async def warm_up_session(session: AsyncSession) -> bool:
|
||||||
await asyncio.sleep(random.uniform(2.0, 3.5))
|
await asyncio.sleep(random.uniform(2.0, 3.5))
|
||||||
try:
|
try:
|
||||||
r = await session.get(
|
r = await session.get(
|
||||||
_AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
|
_AVITO_WARM_SEARCH_URL, headers=referer_headers(_AVITO_WARM_YANDEX_REFERER)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("avito warm-up: search fetch error: %s", exc)
|
logger.warning("avito warm-up: search fetch error: %s", exc)
|
||||||
|
|
@ -292,17 +324,27 @@ async def warm_up_session(session: AsyncSession) -> bool:
|
||||||
ok = r.status_code == 200 and not _is_firewall_page(r.text)
|
ok = r.status_code == 200 and not _is_firewall_page(r.text)
|
||||||
if not ok:
|
if not ok:
|
||||||
logger.warning("avito warm-up: search page blocked sc=%s", r.status_code)
|
logger.warning("avito warm-up: search page blocked sc=%s", r.status_code)
|
||||||
return ok
|
return False
|
||||||
|
if not _has_antibot_cookies(session):
|
||||||
|
raise AvitoWarmupCookiesMissingError(
|
||||||
|
"avito warm-up: search page sc=200 (not firewall) but no antibot cookies "
|
||||||
|
f"({'/'.join(_AVITO_ANTIBOT_COOKIE_PREFIXES)}*) in session — warm-up looked "
|
||||||
|
"OK but Avito didn't mark the session; refusing to burn proxy on a doomed "
|
||||||
|
"detail-batch"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def research_in_session(session: AsyncSession) -> bool:
|
async def research_in_session(session: AsyncSession) -> bool:
|
||||||
"""Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на
|
"""Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=ya.ru) на
|
||||||
ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница
|
ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort по сетевым
|
||||||
иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall.
|
ошибкам/429 (это не ломает detail-фетчи) — но если страница отдалась ОК, а
|
||||||
|
антибот-куки не осели, поднимает AvitoWarmupCookiesMissingError (#3034): тихий
|
||||||
|
возврат True/False тут маскировал бы ту же проблему, что и в warm_up_session.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
r = await session.get(
|
r = await session.get(
|
||||||
_AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER}
|
_AVITO_WARM_SEARCH_URL, headers=referer_headers(_AVITO_WARM_YANDEX_REFERER)
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("avito re-search: fetch error: %s", exc)
|
logger.warning("avito re-search: fetch error: %s", exc)
|
||||||
|
|
@ -310,12 +352,24 @@ async def research_in_session(session: AsyncSession) -> bool:
|
||||||
ok = r.status_code == 200 and not _is_firewall_page(r.text)
|
ok = r.status_code == 200 and not _is_firewall_page(r.text)
|
||||||
if not ok:
|
if not ok:
|
||||||
logger.info("avito re-search: search page sc=%s (non-fatal)", r.status_code)
|
logger.info("avito re-search: search page sc=%s (non-fatal)", r.status_code)
|
||||||
return ok
|
return False
|
||||||
|
if not _has_antibot_cookies(session):
|
||||||
|
raise AvitoWarmupCookiesMissingError(
|
||||||
|
"avito re-search: search page sc=200 (not firewall) but no antibot cookies "
|
||||||
|
f"({'/'.join(_AVITO_ANTIBOT_COOKIE_PREFIXES)}*) in session — in-session "
|
||||||
|
"re-warm looked OK but Avito didn't refresh the anti-bot marker"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def build_warmed_session(config: ScraperConfig | None = None) -> AsyncSession:
|
async def build_warmed_session(config: ScraperConfig | None = None) -> AsyncSession:
|
||||||
"""Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает
|
"""Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает
|
||||||
прогретую сессию (даже если warm-up soft-failed — caller может всё равно пробовать).
|
прогретую сессию (даже если warm-up soft-failed по статусу/firewall — caller
|
||||||
|
может всё равно пробовать). Но если страница отдалась 200/не-firewall, а
|
||||||
|
антибот-cookies не осели — `warm_up_session` поднимает
|
||||||
|
`AvitoWarmupCookiesMissingError` (#3034), и она пробрасывается наружу отсюда
|
||||||
|
БЕЗ перехвата: нет смысла возвращать «прогретую» сессию, которая площадкой не
|
||||||
|
помечена.
|
||||||
|
|
||||||
Strangler-инжекция (#2330): config пробрасывается в _build_detail_session тем же
|
Strangler-инжекция (#2330): config пробрасывается в _build_detail_session тем же
|
||||||
способом, каким это делает fetch_detail — до этого фикса build_warmed_session
|
способом, каким это делает fetch_detail — до этого фикса build_warmed_session
|
||||||
|
|
@ -382,7 +436,7 @@ async def fetch_detail(
|
||||||
"""GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.
|
"""GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.
|
||||||
|
|
||||||
Если browser_fetcher передан — использует браузерный fetch (browser mode).
|
Если browser_fetcher передан — использует браузерный fetch (browser mode).
|
||||||
Если cffi_session не передана — создаёт новую (impersonate='chrome120').
|
Если cffi_session не передана — создаёт новую (impersonate=DEFAULT_IMPERSONATE).
|
||||||
referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
|
referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
|
||||||
referer = URL avito-search страницы, на которой прогрелась сессия).
|
referer = URL avito-search страницы, на которой прогрелась сессия).
|
||||||
reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
|
reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
|
||||||
|
|
@ -459,6 +513,10 @@ async def fetch_detail(
|
||||||
backconnect = (
|
backconnect = (
|
||||||
bool(config.scraper_proxy_url if config is not None else None) and reconnect_on_block
|
bool(config.scraper_proxy_url if config is not None else None) and reconnect_on_block
|
||||||
)
|
)
|
||||||
|
# NB (#3034 scope): этот Referer — URL СВОЕЙ ЖЕ avito-search-страницы (warm-batch
|
||||||
|
# #1551), т.е. same-origin переход внутри avito.ru, а не cross-site. `referer_headers()`
|
||||||
|
# (Sec-Fetch-Site="cross-site") тут НЕ подходит — она только для чужого-домена
|
||||||
|
# warm-up'а (yandex/ya.ru -> avito, см. warm_up_session/research_in_session выше).
|
||||||
req_headers = {"Referer": referer} if referer else None
|
req_headers = {"Referer": referer} if referer else None
|
||||||
r403 = 0
|
r403 = 0
|
||||||
r429 = 0
|
r429 = 0
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ props['miniSerp']['items'], props['housePlacementHistory']['items'] и т.д.).
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
fetch_house_catalog(house_url)
|
fetch_house_catalog(house_url)
|
||||||
→ HTTP GET (curl_cffi chrome120)
|
→ HTTP GET (curl_cffi impersonate=DEFAULT_IMPERSONATE)
|
||||||
→ regex extract __preloadedState__
|
→ regex extract __preloadedState__
|
||||||
→ JS-unescape + json.loads
|
→ JS-unescape + json.loads
|
||||||
→ parse_houses_state(state, house_url)
|
→ parse_houses_state(state, house_url)
|
||||||
|
|
@ -40,6 +40,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
from scraper_kit.avito_exceptions import AvitoBlockedError, AvitoRateLimitedError
|
||||||
from scraper_kit.house_type_normalizer import normalize_house_type
|
from scraper_kit.house_type_normalizer import normalize_house_type
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||||||
from scraper_kit.providers.avito.serp import _is_firewall_page
|
from scraper_kit.providers.avito.serp import _is_firewall_page
|
||||||
from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date
|
from scraper_kit.providers.avito.shared import RUS_MONTHS, _unix_to_date
|
||||||
|
|
||||||
|
|
@ -938,7 +939,7 @@ async def fetch_house_catalog(
|
||||||
|
|
||||||
_own_session = False
|
_own_session = False
|
||||||
if cffi_session is None:
|
if cffi_session is None:
|
||||||
cffi_session = CffiAsyncSession(impersonate="chrome120")
|
cffi_session = CffiAsyncSession(impersonate=DEFAULT_IMPERSONATE)
|
||||||
_own_session = True
|
_own_session = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ tests/fixtures/avito_imv_geo_position.json + avito_imv_getdata.json):
|
||||||
2) POST /js/v2/geo/position → rich JWT (geoFieldsHash)
|
2) POST /js/v2/geo/position → rich JWT (geoFieldsHash)
|
||||||
3) POST /web/1/realty-imv/get-data → price + placementHistory? + suggestions
|
3) POST /web/1/realty-imv/get-data → price + placementHistory? + suggestions
|
||||||
|
|
||||||
Anti-bot: zerkalim AvitoScraper (avito.py) — chrome120 TLS + document-заголовки +
|
Anti-bot: zerkalim AvitoScraper (avito.py) — TLS impersonate=DEFAULT_IMPERSONATE +
|
||||||
warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят 403 с server-IP.
|
document-заголовки + warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят
|
||||||
|
403 с server-IP.
|
||||||
|
|
||||||
Таблицы:
|
Таблицы:
|
||||||
avito_imv_evaluations (018_avito_imv_evaluations.sql)
|
avito_imv_evaluations (018_avito_imv_evaluations.sql)
|
||||||
|
|
@ -33,6 +34,7 @@ from uuid import UUID
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||||||
from scraper_kit.providers._proxy import curl_proxy_url
|
from scraper_kit.providers._proxy import curl_proxy_url
|
||||||
from scraper_kit.providers.avito.shared import _unix_to_date
|
from scraper_kit.providers.avito.shared import _unix_to_date
|
||||||
|
|
||||||
|
|
@ -508,8 +510,9 @@ async def evaluate_via_imv(
|
||||||
_own_session = False
|
_own_session = False
|
||||||
if cffi_session is None:
|
if cffi_session is None:
|
||||||
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
|
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
|
||||||
# chrome120 TLS + document-заголовки + timeout. Затем warm-up GET для
|
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout.
|
||||||
# seed anti-bot cookies — bare-session XHR Avito банит на server-IP.
|
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito
|
||||||
|
# банит на server-IP.
|
||||||
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
||||||
# scraper_proxy_url. proxy=None → прямое подключение (dev).
|
# scraper_proxy_url. proxy=None → прямое подключение (dev).
|
||||||
#
|
#
|
||||||
|
|
@ -535,7 +538,7 @@ async def evaluate_via_imv(
|
||||||
)
|
)
|
||||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||||
cffi_session = CffiAsyncSession(
|
cffi_session = CffiAsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
timeout=_HTTP_TIMEOUT_SEC,
|
timeout=_HTTP_TIMEOUT_SEC,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
headers=_DOC_HEADERS,
|
headers=_DOC_HEADERS,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ URL patterns (EKB):
|
||||||
https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-slug>?s=104&p=1
|
https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-slug>?s=104&p=1
|
||||||
|
|
||||||
ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
|
ВАЖНО: Avito банит httpx (403/429) по TLS fingerprint от server IP.
|
||||||
Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello.
|
Используем curl_cffi с impersonate=DEFAULT_IMPERSONATE — настоящий Chrome TLS ClientHello
|
||||||
|
(scraper_kit.providers._base — единственный источник значения, #3034).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -366,7 +367,7 @@ def _is_firewall_page(html: str) -> bool:
|
||||||
class AvitoScraper(BaseScraper):
|
class AvitoScraper(BaseScraper):
|
||||||
"""Avito vtorichka parser. Источник = 'avito'.
|
"""Avito vtorichka parser. Источник = 'avito'.
|
||||||
|
|
||||||
Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана.
|
Использует curl_cffi с impersonate=DEFAULT_IMPERSONATE для обхода TLS fingerprint бана.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "avito"
|
name = "avito"
|
||||||
|
|
@ -439,7 +440,7 @@ class AvitoScraper(BaseScraper):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _build_cffi_session(self) -> AsyncSession:
|
def _build_cffi_session(self) -> AsyncSession:
|
||||||
"""Создать curl_cffi-сессию (impersonate chrome120, через прокси если задан).
|
"""Создать curl_cffi-сессию (impersonate=DEFAULT_IMPERSONATE, через прокси если задан).
|
||||||
|
|
||||||
Вынесено из __aenter__ чтобы _reset_cffi мог пересоздать сессию (новый
|
Вынесено из __aenter__ чтобы _reset_cffi мог пересоздать сессию (новый
|
||||||
CONNECT-туннель) для escape 403 на залоченном backconnect exit-IP.
|
CONNECT-туннель) для escape 403 на залоченном backconnect exit-IP.
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ async def verify_session(
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Hit Cian Valuation Calculator with cookies — return parsed state if authenticated.
|
"""Hit Cian Valuation Calculator with cookies — return parsed state if authenticated.
|
||||||
|
|
||||||
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
|
Uses curl_cffi with impersonate=DEFAULT_IMPERSONATE (same as prod scrapers) to avoid
|
||||||
TLS-fingerprint bans that httpx would trigger.
|
TLS-fingerprint bans that httpx would trigger.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from scraper_kit.cian_state_parser import extract_state
|
from scraper_kit.cian_state_parser import extract_state
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||||||
from scraper_kit.providers._proxy import curl_proxy_url
|
from scraper_kit.providers._proxy import curl_proxy_url
|
||||||
from scraper_kit.providers.cian.session import load_session, mark_session_invalid
|
from scraper_kit.providers.cian.session import load_session, mark_session_invalid
|
||||||
|
|
||||||
|
|
@ -180,7 +181,7 @@ async def estimate_via_cian_valuation(
|
||||||
}
|
}
|
||||||
url = f"{VALUATION_BASE_URL}?{urlencode(params, safe='[]')}"
|
url = f"{VALUATION_BASE_URL}?{urlencode(params, safe='[]')}"
|
||||||
|
|
||||||
# 4. Fetch с TLS fingerprint (curl_cffi, impersonate chrome120)
|
# 4. Fetch с TLS fingerprint (curl_cffi, impersonate=DEFAULT_IMPERSONATE)
|
||||||
# Mobile proxy wiring (#806 follow-up): Cian валюация — такой же datacenter-бан риск
|
# Mobile proxy wiring (#806 follow-up): Cian валюация — такой же datacenter-бан риск
|
||||||
# как SERP. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env cian_proxy_url.
|
# как SERP. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env cian_proxy_url.
|
||||||
# proxy=None → прямое подключение (dev). curl_proxy_url на выходе mark_health + release.
|
# proxy=None → прямое подключение (dev). curl_proxy_url на выходе mark_health + release.
|
||||||
|
|
@ -190,7 +191,7 @@ async def estimate_via_cian_valuation(
|
||||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||||
try:
|
try:
|
||||||
async with AsyncSession(
|
async with AsyncSession(
|
||||||
impersonate="chrome120",
|
impersonate=DEFAULT_IMPERSONATE,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
timeout=25.0,
|
timeout=25.0,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from pydantic import BaseModel, Field
|
||||||
from selectolax.parser import HTMLParser
|
from selectolax.parser import HTMLParser
|
||||||
|
|
||||||
from scraper_kit.base import BaseScraper
|
from scraper_kit.base import BaseScraper
|
||||||
|
from scraper_kit.providers._base import DEFAULT_IMPERSONATE
|
||||||
from scraper_kit.providers._proxy import curl_proxy_url
|
from scraper_kit.providers._proxy import curl_proxy_url
|
||||||
from scraper_kit.yandex_helpers import (
|
from scraper_kit.yandex_helpers import (
|
||||||
parse_dmy,
|
parse_dmy,
|
||||||
|
|
@ -207,7 +208,7 @@ class YandexValuationScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
|
||||||
try:
|
try:
|
||||||
self._cffi_session = _CurlCffiSession(impersonate="chrome120", proxies=_proxies)
|
self._cffi_session = _CurlCffiSession(impersonate=DEFAULT_IMPERSONATE, proxies=_proxies)
|
||||||
except Exception:
|
except Exception:
|
||||||
# session-создание упало → не течём lease'ом
|
# session-создание упало → не течём lease'ом
|
||||||
self._proxy_stack.close()
|
self._proxy_stack.close()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue