fix(tradein/scraper-kit): actualize avito fingerprint - impersonate/Sec-Fetch-Site/referer/cookie-check

Живой замер браузера 2026-08-21 разошёлся с тем, что шлёт прогретая сессия:

- impersonate="chrome120" был захардкожен в 9+ местах (avito/{serp,detail,imv,
  houses}.py, pipeline.py x4, cian/valuation.py, yandex/valuation.py) вместо
  единой DEFAULT_IMPERSONATE (providers/_base.py). Обновление до chrome146
  (макс. доступный профиль curl_cffi 0.15.0; alias "chrome" НЕ используется -
  едет сам при апгрейде библиотеки без ревью) теперь меняется в одном месте.
  Guard-тест backend/tests/test_impersonate_single_source.py грепает всё
  дерево scraper_kit на литерал "chrome120".

- DOCUMENT_HEADERS ставил Sec-Fetch-Site="none" на уровне сессии, а Referer
  добавлялся per-request (curl_cffi мёржит per-request headers поверх
  session-level) - живой Referer с "none" рядом не бывает у настоящего
  Chrome. Добавлен referer_headers() (Sec-Fetch-Site="cross-site") для
  caller'ов с чужедоменным Referer; avito warm-up (yandex/ya.ru -> avito)
  теперь его использует. Внутренний avito-search -> avito-detail Referer
  (fetch_detail, same-origin) НЕ тронут - отдельный явный комментарий почему.

- Referer прогрева заменён с yandex.ru на ya.ru (живой переход из выдачи
  даёт короткий домен). ysclid сознательно не добавлен - значение выпускает
  Яндекс, подделка хуже отсутствия.

- warm_up_session/research_in_session считали прогрев успешным по HTTP-
  статусу и отсутствию firewall-маркеров, не проверяя антибот-cookies
  (__zzatw-*/cfidsw-*, сняты с живого браузера) - detail-батч на такой
  "прогретой" сессии сжигал прокси на обречённых 403. Теперь поднимают
  AvitoWarmupCookiesMissingError (наследник AvitoBlockedError - существующие
  except-блоки/ban_kind/proxy-ротация в pipeline и backfill ловят без
  изменений).

Полный backend pytest suite зелёный (4672 passed), ruff check чист.

Refs #3034
This commit is contained in:
bot-backend 2026-08-21 17:02:53 +03:00
parent e31c537205
commit fe628a5e17
15 changed files with 372 additions and 40 deletions

View file

@ -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) в
# providers/_base.py (#2358 Foundation) — реюзаем вместо копии, разрывая
# зависимость от 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 (
_AVITO_WARM_SEARCH_URL,
build_warmed_session,
@ -285,7 +285,7 @@ async def run_avito_detail_backfill(
# уже даёт явную деградацию run'а с понятным логом, отдельный catch не нужен.
own_session = True
session = AsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=DOCUMENT_HEADERS,
proxies=http_proxies(resolve_proxy_url(db, "avito")),

View file

@ -20,7 +20,7 @@ dependencies = [
"lxml>=5.0.0", # стриминговый iterparse для многогигабайтных ГАР XML (#143)
"segno>=1.6.0", # QR-код для PDF shareable URL
"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.7.0", # impersonate=<profile> для Cian/Avito (TLS fingerprint)
"python-multipart>=0.0.9", # FastAPI UploadFile — загрузка фото квартиры (#394)
"sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396)
"redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2)

View file

@ -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

View 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-"
"профиля. Прочитай константу вместо хардкода строки."
)

View file

@ -24,6 +24,7 @@ from scraper_kit.providers._base import (
build_curl_cffi_session,
build_document_session,
http_proxies,
referer_headers,
)
@ -119,6 +120,37 @@ async def test_build_document_session_custom_timeout_and_proxy() -> None:
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 ──────────────────────────────────────────────────────

View file

@ -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):
"""Cannot parse Avito HTML structure (selector changes)."""

View file

@ -58,6 +58,7 @@ from scraper_kit.orchestration import runs
# Константы диагноза берём напрямую, а не через `runs.` — helper ниже обязан
# работать и когда тесты подменяют весь модуль runs двойником (#2686).
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.houses import (
fetch_house_catalog,
@ -727,7 +728,7 @@ async def run_avito_pipeline(
else:
own_session = shared_session is None
session = shared_session or AsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(config),
@ -1228,7 +1229,7 @@ async def run_avito_city_sweep(
else:
session = await stack.enter_async_context(
AsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(config),
@ -1931,7 +1932,7 @@ async def run_avito_newbuilding_sweep(
else:
session = await stack.enter_async_context(
AsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(config),
@ -2349,7 +2350,7 @@ async def run_yandex_city_sweep(
_yandex_enrich_abort = 3 # abort address-enrich после N подряд
async with AsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
timeout=30.0,
proxies=_proxies,
headers={"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8"},

View file

@ -15,7 +15,7 @@
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/detail.py::_build_detail_session` (256-283, ИДЕНТИЧНЫЙ headers-dict),
`avito/imv.py` (headers=_DOC_HEADERS, тот же dict третий раз), `cian/detail.py`
@ -61,12 +61,30 @@ logger = logging.getLogger(__name__)
# curl_cffi impersonate-профиль, используемый ВСЕМИ providers без исключения
# (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-страниц
# (SERP/detail — НЕ XHR/JSON API-запросы, для них headers не нужны/другие).
# Идентичен ранее продублированному `_DOC_HEADERS` (avito/imv.py) и inline-dict
# в 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] = {
"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",
@ -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:
"""curl_cffi-совместимый `proxies=` dict из одного url.
@ -138,7 +174,7 @@ def build_document_session(
Это ровно та форма, что дублирована в `avito/serp.py::_build_cffi_session` и
`avito/detail.py::_build_detail_session` (identical headers, timeout=25,
impersonate=chrome120) единственная разница между сайтами вызова была
impersonate=DEFAULT_IMPERSONATE) единственная разница между сайтами вызова была
источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но
прокидывалось разными путями).
"""

View file

@ -39,10 +39,11 @@ from scraper_kit.avito_exceptions import (
AvitoListingGoneError,
AvitoRateLimitedError,
AvitoSidecarUnavailableError,
AvitoWarmupCookiesMissingError,
)
from scraper_kit.ceiling_height import plausible_ceiling_m
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.shared import RUS_MONTHS
from scraper_kit.repair_state_normalizer import infer_repair_state_from_text
@ -56,7 +57,7 @@ logger = logging.getLogger(__name__)
AVITO_BASE = "https://www.avito.ru"
# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET
# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая
# Avito EKB search-страница (Referer=ya.ru) сеет антибот-куки, после чего прогретая
# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22
# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы.
_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote(
@ -65,7 +66,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_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-фикса
# (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси;
@ -257,8 +264,8 @@ class DetailEnrichment:
def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
"""Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси
если задан config.scraper_proxy_url).
"""Создать curl_cffi-сессию для detail-fetch (impersonate=DEFAULT_IMPERSONATE, через
прокси если задан config.scraper_proxy_url).
Вынесено из own-session-ветки fetch_detail чтобы reconnect-retry мог пересоздать
эфемерную сессию (новый CONNECT-туннель = свежий backconnect exit-IP) под 403.
@ -271,10 +278,35 @@ def _build_detail_session(config: ScraperConfig | None = None) -> AsyncSession:
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:
"""Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search
(Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
без 403. Возвращает True если search-страница загрузилась (не firewall).
(Referer=ya.ru). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
без 403. Возвращает True если search-страница загрузилась (не firewall) И
антибот-куки реально появились в сессии.
Раньше (#3034) страница могла отдать HTTP 200 без firewall-маркеров, но БЕЗ
антибот-cookies прогрев считался успешным, а detail-батч на такой
«прогретой» сессии сжигал прокси на обречённых 403. Теперь такой случай
поднимает AvitoWarmupCookiesMissingError вместо тихого `return True`.
"""
try:
await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort
@ -283,7 +315,7 @@ async def warm_up_session(session: AsyncSession) -> bool:
await asyncio.sleep(random.uniform(2.0, 3.5))
try:
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:
logger.warning("avito warm-up: search fetch error: %s", exc)
@ -291,17 +323,27 @@ async def warm_up_session(session: AsyncSession) -> bool:
ok = r.status_code == 200 and not _is_firewall_page(r.text)
if not ok:
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:
"""Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на
ТЕКУЩЕЙ сессии освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница
иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall.
"""Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=ya.ru) на
ТЕКУЩЕЙ сессии освежает антибот-куки БЕЗ смены exit-IP. Best-effort по сетевым
ошибкам/429 (это не ломает detail-фетчи) но если страница отдалась ОК, а
антибот-куки не осели, поднимает AvitoWarmupCookiesMissingError (#3034): тихий
возврат True/False тут маскировал бы ту же проблему, что и в warm_up_session.
"""
try:
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:
logger.warning("avito re-search: fetch error: %s", exc)
@ -309,12 +351,24 @@ async def research_in_session(session: AsyncSession) -> bool:
ok = r.status_code == 200 and not _is_firewall_page(r.text)
if not ok:
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:
"""Построить 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 тем же
способом, каким это делает fetch_detail до этого фикса build_warmed_session
@ -381,7 +435,7 @@ async def fetch_detail(
"""GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.
Если browser_fetcher передан использует браузерный fetch (browser mode).
Если cffi_session не передана создаёт новую (impersonate='chrome120').
Если cffi_session не передана создаёт новую (impersonate=DEFAULT_IMPERSONATE).
referer если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
referer = URL avito-search страницы, на которой прогрелась сессия).
reconnect_on_block если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
@ -458,6 +512,10 @@ async def fetch_detail(
backconnect = (
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
r403 = 0
r429 = 0

View file

@ -11,7 +11,7 @@ props['miniSerp']['items'], props['housePlacementHistory']['items'] и т.д.).
Flow:
fetch_house_catalog(house_url)
HTTP GET (curl_cffi chrome120)
HTTP GET (curl_cffi impersonate=DEFAULT_IMPERSONATE)
regex extract __preloadedState__
JS-unescape + json.loads
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.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.shared import RUS_MONTHS, _unix_to_date
@ -938,7 +939,7 @@ async def fetch_house_catalog(
_own_session = False
if cffi_session is None:
cffi_session = CffiAsyncSession(impersonate="chrome120")
cffi_session = CffiAsyncSession(impersonate=DEFAULT_IMPERSONATE)
_own_session = True
try:

View file

@ -9,8 +9,9 @@ tests/fixtures/avito_imv_geo_position.json + avito_imv_getdata.json):
2) POST /js/v2/geo/position rich JWT (geoFieldsHash)
3) POST /web/1/realty-imv/get-data price + placementHistory? + suggestions
Anti-bot: zerkalim AvitoScraper (avito.py) chrome120 TLS + document-заголовки +
warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят 403 с server-IP.
Anti-bot: zerkalim AvitoScraper (avito.py) TLS impersonate=DEFAULT_IMPERSONATE +
document-заголовки + warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят
403 с server-IP.
Таблицы:
avito_imv_evaluations (018_avito_imv_evaluations.sql)
@ -33,6 +34,7 @@ from uuid import UUID
from sqlalchemy import text
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.avito.shared import _unix_to_date
@ -508,8 +510,9 @@ async def evaluate_via_imv(
_own_session = False
if cffi_session is None:
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
# chrome120 TLS + document-заголовки + timeout. Затем warm-up GET для
# seed anti-bot cookies — bare-session XHR Avito банит на server-IP.
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout.
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito
# банит на server-IP.
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
# 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
cffi_session = CffiAsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
timeout=_HTTP_TIMEOUT_SEC,
proxies=_proxies,
headers=_DOC_HEADERS,

View file

@ -18,7 +18,8 @@ URL patterns (EKB):
https://www.avito.ru/ekaterinburg/kvartiry/prodam/<room-slug>?s=104&p=1
ВАЖНО: 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
@ -337,7 +338,7 @@ def _is_firewall_page(html: str) -> bool:
class AvitoScraper(BaseScraper):
"""Avito vtorichka parser. Источник = 'avito'.
Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана.
Использует curl_cffi с impersonate=DEFAULT_IMPERSONATE для обхода TLS fingerprint бана.
"""
name = "avito"
@ -402,7 +403,7 @@ class AvitoScraper(BaseScraper):
return self
def _build_cffi_session(self) -> AsyncSession:
"""Создать curl_cffi-сессию (impersonate chrome120, через прокси если задан).
"""Создать curl_cffi-сессию (impersonate=DEFAULT_IMPERSONATE, через прокси если задан).
Вынесено из __aenter__ чтобы _reset_cffi мог пересоздать сессию (новый
CONNECT-туннель) для escape 403 на залоченном backconnect exit-IP.

View file

@ -103,7 +103,7 @@ async def verify_session(
) -> dict[str, Any] | None:
"""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.
Returns:

View file

@ -33,6 +33,7 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
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.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='[]')}"
# 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-бан риск
# как SERP. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env cian_proxy_url.
# 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
try:
async with AsyncSession(
impersonate="chrome120",
impersonate=DEFAULT_IMPERSONATE,
cookies=cookies,
timeout=25.0,
proxies=_proxies,

View file

@ -35,6 +35,7 @@ from pydantic import BaseModel, Field
from selectolax.parser import HTMLParser
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.yandex_helpers import (
parse_dmy,
@ -207,7 +208,7 @@ class YandexValuationScraper(BaseScraper):
)
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
try:
self._cffi_session = _CurlCffiSession(impersonate="chrome120", proxies=_proxies)
self._cffi_session = _CurlCffiSession(impersonate=DEFAULT_IMPERSONATE, proxies=_proxies)
except Exception:
# session-создание упало → не течём lease'ом
self._proxy_stack.close()