diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index d572c1bd..658cafe1 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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")), diff --git a/tradein-mvp/backend/pyproject.toml b/tradein-mvp/backend/pyproject.toml index ee679c0b..8116d3fc 100644 --- a/tradein-mvp/backend/pyproject.toml +++ b/tradein-mvp/backend/pyproject.toml @@ -20,7 +20,10 @@ 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.15.0", # impersonate= для 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) "sentry-sdk>=2.0.0", # мониторинг ошибок → GlitchTip (#396) "redis>=5.0.0", # async hot cache для /api/v1/search (Phase 3.2) diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py new file mode 100644 index 00000000..332c5534 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup_antibot_cookies.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_impersonate_single_source.py b/tradein-mvp/backend/tests/test_impersonate_single_source.py new file mode 100644 index 00000000..4388b25e --- /dev/null +++ b/tradein-mvp/backend/tests/test_impersonate_single_source.py @@ -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-" + "профиля. Прочитай константу вместо хардкода строки." + ) diff --git a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py index 7fb52207..817783dd 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py @@ -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 ────────────────────────────────────────────────────── diff --git a/tradein-mvp/packages/scraper-kit/pyproject.toml b/tradein-mvp/packages/scraper-kit/pyproject.toml index 494e89d8..dc82b236 100644 --- a/tradein-mvp/packages/scraper-kit/pyproject.toml +++ b/tradein-mvp/packages/scraper-kit/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "httpx[socks]>=0.27.0", "tenacity>=9.0.0", "selectolax>=0.3.0", - "curl-cffi>=0.7.0", + "curl-cffi>=0.15.0", # пол задан профилем chrome146 из DEFAULT_IMPERSONATE, см. providers/_base.py ] [build-system] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/avito_exceptions.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/avito_exceptions.py index 18e5d5bf..392d8033 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/avito_exceptions.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/avito_exceptions.py @@ -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).""" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index 5b25da85..c0dd2bf2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -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"}, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index 422998b9..81e0aff9 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -15,7 +15,7 @@ endpoint/use_pool технически невозможно, баг-класс закрыт структурно, а не точечным патчем. -2. **curl_cffi `AsyncSession(impersonate="chrome120", proxies=..., headers=...)` +2. **curl_cffi `AsyncSession(impersonate=, 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` в обоих случаях, но прокидывалось разными путями). """ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py index 90705a4e..7d2cc2ed 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -63,10 +63,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 @@ -80,7 +81,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( @@ -89,7 +90,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-прокси; @@ -298,8 +305,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. @@ -312,10 +319,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 @@ -324,7 +356,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) @@ -332,17 +364,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) @@ -350,12 +392,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 @@ -422,7 +476,7 @@ async def fetch_detail( """GET /{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 СРАЗУ @@ -499,6 +553,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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py index c91b256b..174f4f5d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/houses.py @@ -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: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py index 30e609f2..9c84e2a2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -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, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 7daf9c09..a5d42e36 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -18,7 +18,8 @@ URL patterns (EKB): https://www.avito.ru/ekaterinburg/kvartiry/prodam/?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 @@ -366,7 +367,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" @@ -439,7 +440,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. diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/session.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/session.py index 8077232f..b3a90ba2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/session.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/session.py @@ -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: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py index 17e51f7d..42cf75d7 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/valuation.py @@ -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, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py index 589be38e..a6f21abc 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/valuation.py @@ -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()