Compare commits
No commits in common. "4c9f5b6e9fa482cc36ea743e90128524381cabc7" and "f34edeb5129c38db2164270459ae614d3c7fe675" have entirely different histories.
4c9f5b6e9f
...
f34edeb512
16 changed files with 41 additions and 376 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 DEFAULT_IMPERSONATE, DOCUMENT_HEADERS, http_proxies
|
from scraper_kit.providers._base import 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=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
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,10 +20,7 @@ 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.15.0", # impersonate=<profile> для Cian/Avito (TLS fingerprint).
|
"curl-cffi>=0.7.0", # impersonate=chrome120 для 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)
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
"""#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
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"""Единственный источник правды для 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,7 +24,6 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -120,37 +119,6 @@ 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.15.0", # пол задан профилем chrome146 из DEFAULT_IMPERSONATE, см. providers/_base.py
|
"curl-cffi>=0.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|
|
||||||
|
|
@ -60,18 +60,5 @@ 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,7 +58,6 @@ 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,
|
||||||
|
|
@ -728,7 +727,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=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=_CHROME_HEADERS,
|
headers=_CHROME_HEADERS,
|
||||||
proxies=_avito_proxies(config),
|
proxies=_avito_proxies(config),
|
||||||
|
|
@ -1229,7 +1228,7 @@ async def run_avito_city_sweep(
|
||||||
else:
|
else:
|
||||||
session = await stack.enter_async_context(
|
session = await stack.enter_async_context(
|
||||||
AsyncSession(
|
AsyncSession(
|
||||||
impersonate=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=_CHROME_HEADERS,
|
headers=_CHROME_HEADERS,
|
||||||
proxies=_avito_proxies(config),
|
proxies=_avito_proxies(config),
|
||||||
|
|
@ -1932,7 +1931,7 @@ async def run_avito_newbuilding_sweep(
|
||||||
else:
|
else:
|
||||||
session = await stack.enter_async_context(
|
session = await stack.enter_async_context(
|
||||||
AsyncSession(
|
AsyncSession(
|
||||||
impersonate=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
timeout=25,
|
timeout=25,
|
||||||
headers=_CHROME_HEADERS,
|
headers=_CHROME_HEADERS,
|
||||||
proxies=_avito_proxies(config),
|
proxies=_avito_proxies(config),
|
||||||
|
|
@ -2350,7 +2349,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=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
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=<profile>, proxies=..., headers=...)`
|
2. **curl_cffi `AsyncSession(impersonate="chrome120", 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,30 +61,12 @@ 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",
|
||||||
|
|
@ -97,24 +79,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -174,7 +138,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=DEFAULT_IMPERSONATE) — единственная разница между сайтами вызова была
|
impersonate=chrome120) — единственная разница между сайтами вызова была
|
||||||
источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но
|
источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но
|
||||||
прокидывалось разными путями).
|
прокидывалось разными путями).
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,10 @@ 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, referer_headers
|
from scraper_kit.providers._base import build_document_session
|
||||||
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
|
||||||
|
|
@ -81,7 +80,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=ya.ru) сеет антибот-куки, после чего прогретая
|
# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая
|
||||||
# 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(
|
||||||
|
|
@ -90,13 +89,7 @@ _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(
|
||||||
"купить квартиру екатеринбург авито"
|
"купить квартиру екатеринбург авито"
|
||||||
)
|
)
|
||||||
# #3034: живой переход из яндексовой выдачи (замер 2026-08-21) даёт Referer с
|
_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/"
|
||||||
# короткого домена 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-прокси;
|
||||||
|
|
@ -305,8 +298,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=DEFAULT_IMPERSONATE, через
|
"""Создать curl_cffi-сессию для detail-fetch (impersonate chrome120, через прокси
|
||||||
прокси если задан 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.
|
||||||
|
|
@ -319,35 +312,10 @@ 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=ya.ru). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов
|
(Referer=yandex). Сеет антибот-куки, после чего сессия держит батч 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
|
||||||
|
|
@ -356,7 +324,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_headers(_AVITO_WARM_YANDEX_REFERER)
|
_AVITO_WARM_SEARCH_URL, headers={"Referer": _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)
|
||||||
|
|
@ -364,27 +332,17 @@ 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 False
|
return ok
|
||||||
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=ya.ru) на
|
"""Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на
|
||||||
ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort по сетевым
|
ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница
|
||||||
ошибкам/429 (это не ломает detail-фетчи) — но если страница отдалась ОК, а
|
иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall.
|
||||||
антибот-куки не осели, поднимает AvitoWarmupCookiesMissingError (#3034): тихий
|
|
||||||
возврат True/False тут маскировал бы ту же проблему, что и в warm_up_session.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
r = await session.get(
|
r = await session.get(
|
||||||
_AVITO_WARM_SEARCH_URL, headers=referer_headers(_AVITO_WARM_YANDEX_REFERER)
|
_AVITO_WARM_SEARCH_URL, headers={"Referer": _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)
|
||||||
|
|
@ -392,24 +350,12 @@ 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 False
|
return ok
|
||||||
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 по статусу/firewall — caller
|
прогретую сессию (даже если warm-up soft-failed — 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
|
||||||
|
|
@ -476,7 +422,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=DEFAULT_IMPERSONATE).
|
Если cffi_session не передана — создаёт новую (impersonate='chrome120').
|
||||||
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 СРАЗУ
|
||||||
|
|
@ -553,10 +499,6 @@ 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 impersonate=DEFAULT_IMPERSONATE)
|
→ HTTP GET (curl_cffi chrome120)
|
||||||
→ 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,7 +40,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -939,7 +938,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=DEFAULT_IMPERSONATE)
|
cffi_session = CffiAsyncSession(impersonate="chrome120")
|
||||||
_own_session = True
|
_own_session = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ 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) — TLS impersonate=DEFAULT_IMPERSONATE +
|
Anti-bot: zerkalim AvitoScraper (avito.py) — chrome120 TLS + document-заголовки +
|
||||||
document-заголовки + warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят
|
warm-up GET + XHR Sec-Fetch заголовки. Bare-session XHR ловят 403 с server-IP.
|
||||||
403 с server-IP.
|
|
||||||
|
|
||||||
Таблицы:
|
Таблицы:
|
||||||
avito_imv_evaluations (018_avito_imv_evaluations.sql)
|
avito_imv_evaluations (018_avito_imv_evaluations.sql)
|
||||||
|
|
@ -34,7 +33,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -510,9 +508,8 @@ 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):
|
||||||
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout.
|
# chrome120 TLS + document-заголовки + timeout. Затем warm-up GET для
|
||||||
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito
|
# seed anti-bot cookies — bare-session XHR Avito банит на server-IP.
|
||||||
# банит на 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).
|
||||||
#
|
#
|
||||||
|
|
@ -538,7 +535,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=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
timeout=_HTTP_TIMEOUT_SEC,
|
timeout=_HTTP_TIMEOUT_SEC,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
headers=_DOC_HEADERS,
|
headers=_DOC_HEADERS,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ 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=DEFAULT_IMPERSONATE — настоящий Chrome TLS ClientHello
|
Используем curl_cffi с impersonate='chrome120' — настоящий Chrome TLS ClientHello.
|
||||||
(scraper_kit.providers._base — единственный источник значения, #3034).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -367,7 +366,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=DEFAULT_IMPERSONATE для обхода TLS fingerprint бана.
|
Использует curl_cffi с impersonate=chrome120 для обхода TLS fingerprint бана.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = "avito"
|
name = "avito"
|
||||||
|
|
@ -440,7 +439,7 @@ class AvitoScraper(BaseScraper):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _build_cffi_session(self) -> AsyncSession:
|
def _build_cffi_session(self) -> AsyncSession:
|
||||||
"""Создать curl_cffi-сессию (impersonate=DEFAULT_IMPERSONATE, через прокси если задан).
|
"""Создать curl_cffi-сессию (impersonate chrome120, через прокси если задан).
|
||||||
|
|
||||||
Вынесено из __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=DEFAULT_IMPERSONATE (same as prod scrapers) to avoid
|
Uses curl_cffi with impersonate='chrome120' (same as prod scrapers) to avoid
|
||||||
TLS-fingerprint bans that httpx would trigger.
|
TLS-fingerprint bans that httpx would trigger.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -181,7 +180,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=DEFAULT_IMPERSONATE)
|
# 4. Fetch с TLS fingerprint (curl_cffi, impersonate chrome120)
|
||||||
# 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.
|
||||||
|
|
@ -191,7 +190,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=DEFAULT_IMPERSONATE,
|
impersonate="chrome120",
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
timeout=25.0,
|
timeout=25.0,
|
||||||
proxies=_proxies,
|
proxies=_proxies,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ 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,
|
||||||
|
|
@ -208,7 +207,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=DEFAULT_IMPERSONATE, proxies=_proxies)
|
self._cffi_session = _CurlCffiSession(impersonate="chrome120", 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