Живой замер браузера 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
67 lines
4 KiB
Python
67 lines
4 KiB
Python
"""Единственный источник правды для 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-"
|
||
"профиля. Прочитай константу вместо хардкода строки."
|
||
)
|