Merge pull request 'fix(tradein/domclick): перезапуск браузера на карточку уничтожал пропуск QRATOR (#3212)' (#3217) from fix/3212-domclick-keep-context into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m19s
Deploy Trade-In / test (push) Has been cancelled

This commit is contained in:
lekss361 2026-08-29 11:16:39 +00:00
commit fbc8e89b6f
4 changed files with 98 additions and 67 deletions

View file

@ -54,9 +54,8 @@ Exception triad differs from Avito:
передаётся. Один и тот же DomClickBlockedError по-прежнему поднимается и на
распознанном маркере, и на сбое браузерного fetch, но несёт HTTP-статус ответа
(`.status`, см. providers/domclick/detail.py::fetch_detail) разводить типы
исключений ради этого не понадобилось. 403/429 'platform' (ровно тот случай,
что намеряли вживую 28.08: статическая страница `403 | Домклик` на 26 624 байта,
без PoW и капчи это отказ, а не челлендж), 5xx 'infra', статуса нет
исключений ради этого не понадобилось. 403/429 'platform' (страница
`403 | Домклик` на 26 624 байта, снятая вживую 28.08), 5xx 'infra', статуса нет
'unknown', честно «не установлено». Раньше все 14 прогонов за 14 дней
получали 'unknown' просто потому, что диагноз не доезжал до финализатора.
- DomClickParseError (__SSR_STATE__ missing/malformed -- schema drift, NOT a
@ -164,11 +163,18 @@ def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
)
# 401 у ДомКлика — тот же отказ площадки, что 403 (замер прод 2026-08-29): страница отказа
# при 401 РОВНО 26 624 байта, байт в байт совпадает со страницей `403 | Домклик`
# (снята 28.08.2026), без PoW/QRATOR/капчи. Приходит одинаково и с валидной сохранённой
# сессией (16 куков domclick_session), и полностью анонимно — значит это не «сессия
# отвергнута» (тогда анонимный запрос вёл бы себя иначе), а WAF-отказ с подменённым кодом.
# 401 у ДомКлика — отказ площадки, а не «наша сессия протухла» (замер прод 2026-08-29):
# страница при 401 РОВНО 26 624 байта, байт в байт совпадает со страницей `403 | Домклик`
# (снята 28.08.2026). Приходит одинаково и с валидной сохранённой сессией (16 куков
# domclick_session), и полностью анонимно — значит дело не в сессии, а в вердикте WAF.
#
# ВАЖНО, #3212: ранняя формулировка «без PoW/QRATOR/капчи, это отказ, а не челлендж»
# была НЕВЕРНА и здесь снята. Челлендж есть: QRATOR отдаёт 401 с крошечной заглушкой,
# браузер считает proof-of-work и дёргает /__qrator/validate — принятая валидация даёт
# 200 и пропуск в куках, ОТВЕРГНУТАЯ даёт 403 и вот эту самую страницу на 26 624 байта.
# То есть 26 624 байта — не «статика вместо челленджа», а РЕЗУЛЬТАТ проваленного
# челленджа. Ошибка вышла из метода: HTML читали на 4.5-й секунде, когда заглушка уже
# заменена, и ни разу не смотрели в сетевой лог.
# Правка ДОМЕННАЯ, не в общей ban_kind_from_status (scraper_kit.browser_fetcher): у других
# поставщиков 401 обычно значит «наша сессия протухла» (наша сторона) — общая метка
# 'platform' там зря запустила бы ротацию IP (#2611).
@ -298,6 +304,9 @@ async def run_domclick_detail_backfill(
)
consecutive_blocks = 0
# #3212: сброс переиспользуемого context'а разрешён РОВНО ОДИН раз за прогон.
# Причина ниже, у самого вызова request_context_reset.
context_reset_used = False
aborted_by_blocks = False
do_sleep = False
# #3196: перепись диагнозов ВСЕХ блоков прогона (kind -> сколько раз);
@ -377,10 +386,17 @@ async def run_domclick_detail_backfill(
counters.blocked += 1
ban_kind = _ban_kind_of_block(e)
block_ban_kinds[ban_kind] += 1
# #3118: сожжённый переиспользуемый context (протухший qrator_jsid2
# / стухшая сессия) не должен тянуться дальше по прогону — просим
# ОДИН сброс на следующем fetch(), не на каждый последующий запрос.
bf.request_context_reset()
# #3118 просил сброс на КАЖДЫЙ блок — и этим сам себя блокировал.
# Пропуск QRATOR (куки qrator_jsid2 + qrator_jsr) живёт в context'е;
# сброс его выбрасывает, а повторная валидация с того же IP сразу
# после принятой получает 403. То есть сброс на каждый блок делает
# из одной осечки необратимый каскад: блок → сброс → гарантированный
# блок → сброс… Ровно это и дало приёмке #3118 «1 успех из 10».
# #3212: одна попытка начать с чистого листа за прогон — на случай
# действительно протухшей сессии (TTL пропуска ~2.5ч) — и всё.
if not context_reset_used:
context_reset_used = True
bf.request_context_reset()
logger.warning(
"domclick_detail_backfill: run_id=%d BLOCKED #%d/%d "
"(consecutive=%d, http=%s, kind=%s): %s",

View file

@ -7,11 +7,14 @@
карточки в тёплом browser-контексте = 5/5 успешно.
Фикс: BrowserFetcher(reuse_context=True) держит per-provider context живым между
вызовами; куки вливаются один раз при его создании. При обнаруженном блоке следующий
fetch должен сбросить сожжённый context так как fetch_detail() (scraper_kit,
намеренно не в scope этого фикса) не прокидывает reset_context, оркестратор сигналит
через bf.request_context_reset() (BrowserFetcher: отложенный флаг, потребляется РОВНО
одним следующим fetch()).
вызовами; куки вливаются один раз при его создании. Сброс сожжённого context'а
оркестратор просит через bf.request_context_reset() так как fetch_detail()
(scraper_kit, намеренно не в scope того фикса) не прокидывает reset_context.
#3212 сузил условие сброса: НЕ на каждый блок, а один раз за прогон. Пропуск QRATOR
(qrator_jsid2 + qrator_jsr) живёт в контексте, и сброс его выбрасывает; повторная
валидация с того же IP сразу после принятой получает 403. Сброс на каждый блок поэтому
гарантировал следующий блок приёмка #3118 и дала «1 успех из 10».
Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers).
@ -109,12 +112,14 @@ async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
@pytest.mark.asyncio
async def test_block_triggers_exactly_one_context_reset_request() -> None:
"""DomClickBlockedError -> bf.request_context_reset() вызывается РОВНО один раз на блок.
async def test_context_reset_requested_at_most_once_per_run() -> None:
"""#3212: сброс контекста запрашивается РОВНО ОДИН раз за прогон, а не на каждый блок.
3 подряд блока (max_consecutive_blocks=3) -> сброс запрашивается 3 раза (по разу
НА КАЖДЫЙ обнаруженный блок), а не один раз на весь прогон и не на каждый успешный
fetch между блоками.
До #3212 сброс шёл на КАЖДЫЙ блок, и это само себя поддерживало: в контексте лежит
пропуск QRATOR, сброс его выбрасывает, повторная валидация с того же IP сразу после
принятой получает 403 то есть следующий фетч блокировался гарантированно. Одна
осечка превращалась в необратимый каскад (приёмка #3118: 1 успех из 10).
Здесь 3 подряд блока -> сброс запрошен ровно 1 раз.
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
@ -139,8 +144,8 @@ async def test_block_triggers_exactly_one_context_reset_request() -> None:
)
assert result.blocked == 3
assert instance.request_context_reset.call_count == 3, (
"ожидался ровно один запрос сброса НА КАЖДЫЙ обнаруженный блок"
assert instance.request_context_reset.call_count == 1, (
"ожидалась ровно одна попытка начать с чистого листа за весь прогон"
)

View file

@ -89,21 +89,13 @@ Per-provider модель (#1793):
BROWSER_RECYCLE_PAGES_{PROVIDER} per-provider override recycle-порога (#3205).
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC,
DOMCLICK. Приоритет: per-provider env глобальный
BROWSER_RECYCLE_PAGES код-дефолт per-provider (сейчас
только domclick=1) общий фолбэк 15. Живой замер
2026-08-29 (#3205, прод): ДомКлик отдаёт РОВНО одну
карточку на процесс camoufox три независимых прогона,
карточка #1 всегда 200 (~1МБ SSR-стейт), карточка #2
всегда 401 (~26625 байт страницы отказа); A/B на восьми
карточках свой браузер на карточку 4/4 успеха, один
браузер на четыре 1/4. Признак живёт на уровне ПРОЦЕССА
(camoufox генерирует отпечаток при запуске, а не при
создании context'а) — свежий context (browser.new_page())
отказ со второй карточки НЕ снимает, значит
reset_context/reuse_context (#3118) эту проблему не решают:
нужен новый процесс. Цена перезапуска ~0.6с (первый
холодный запуск ~3.5с). avito/cian/yandex остаются на 15
свой узор бана, отдельного замера под recycle=1 нет.
BROWSER_RECYCLE_PAGES код-дефолт per-provider
(_RECYCLE_PAGES_DEFAULT_BY_PROVIDER, сейчас пуст)
общий фолбэк 15. Ручка оставлена для будущих исключений;
заведённый в #3205 domclick=1 снят в #3212 — перезапуск
процесса уничтожает browser context, а в нём лежит пропуск
QRATOR, ради которого всё и затевалось (подробности у
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER).
SCRAPER_PROXY_URL http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/
yandex/generic). #2616 шаг 2: per-provider
BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy
@ -190,9 +182,9 @@ FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
# "domclick" (#3205): собственный инстанс — площадка отдаёт РОВНО одну карточку на
# процесс camoufox (см. _RECYCLE_PAGES_BY_PROVIDER ниже), общий с generic счётчик
# страниц пачкал бы чужие запросы чужим порогом перезапуска.
# "domclick" (#3205): собственный инстанс — у площадки свой антибот (QRATOR с
# proof-of-work) и свой ритм, и держать её на общем с generic счётчике страниц и
# общем браузере значило бы мешать её сессию с чужими запросами.
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick")
@ -320,19 +312,20 @@ def _resolve_block_images(
# Читается в _launch_browser; логируется на старте (_on_startup).
_BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = {p: _resolve_block_images(p) for p in PROVIDERS}
# Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ (#3205). ДомКлик отдаёт РОВНО одну карточку на
# процесс camoufox — вторая и далее получают 401-страницу отказа (~26625 байт) вместо
# ~1МБ карточки с SSR-стейтом. Признак живёт на уровне ПРОЦЕССА (camoufox генерирует
# отпечаток при запуске, не при создании context'а): в замере browser.new_page() на
# каждую карточку (свежий изолированный context) отказ со второй карточки всё равно
# случался — значит reuse_context/reset_context (#3118) эту задачу НЕ решает, нужен
# новый процесс, а не новый context. Живой замер 2026-08-29 (#3205): три независимых
# прогона через camoufox+прод-прокси — карточка #1 всегда 200, карточка #2 всегда 401;
# A/B на восьми карточках — свой браузер на каждую 4/4 успеха, один браузер на четыре
# 1/4. Цена перезапуска процесса ~0.6с (первый холодный запуск ~3.5с) — дёшево против
# цены отказа. avito/cian/yandex НЕ трогаем — узор бана у них другой, отдельного замера
# под recycle=1 нет, дефолт 15 остаётся.
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {"domclick": 1}
# Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ. Пуст: сейчас все поставщики живут на общем
# фолбэке, ручка per-provider (env + этот словарь) остаётся для будущих исключений.
#
# #3205 заводил здесь domclick=1 — перезапуск процесса camoufox после КАЖДОЙ карточки.
# #3212 это снял: перезапуск уничтожает browser context, а в контексте лежит пропуск
# QRATOR (куки qrator_jsid2 + qrator_jsr), который площадка выдаёт после принятой
# валидации /__qrator/validate. То есть #3205 боролся с симптомом, который сам же и
# создавал. Замер (прод, прод-прокси, по 6 карточек на условие): общий контекст — 6/6
# и НИ ОДНОГО повторного вызова validate; новый контекст на карточку — 1/6, и на каждой
# следующей validate отвечает 403. Вкладки роли не играют, играет общий cookie jar.
# Рассуждение #3205 «признак живёт на уровне процесса, свежий контекст отказ не снимает»
# было верным наблюдением с неверным выводом: свежий контекст его действительно не
# снимает — потому что он и есть причина.
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {}
_RECYCLE_PAGES_DEFAULT_FALLBACK: int = 15

View file

@ -5,8 +5,11 @@
2. глобальный env перебивает код-дефолт per-provider;
3. невалидное (не int) значение фолбэк, без исключения;
4. значение <1 приводится к 1;
5. domclick без env код-дефолт 1 (живой замер #3205: одна карточка на процесс);
6. avito/cian/yandex без env общий фолбэк 15 (их узор бана не менялся).
5. код-дефолт per-provider перебивает общий фолбэк (#3212: словарь
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER сейчас ПУСТ domclick=1 снят, потому что
перезапуск процесса уничтожал пропуск QRATOR; уровень приоритета остался и
проверяется через подставной словарь, а не через живого поставщика);
6. все поставщики без env общий фолбэк 15.
camoufox/Playwright НЕ поднимается: тестируется чистая функция резолвера.
@ -34,8 +37,9 @@ def test_per_provider_env_overrides_global() -> None:
assert server._resolve_recycle_pages("domclick", fake_env) == 3
def test_global_env_overrides_code_default() -> None:
"""Нет per-provider env, но задан глобальный → перебивает код-дефолт domclick=1."""
def test_global_env_overrides_code_default(monkeypatch) -> None:
"""Нет per-provider env, но задан глобальный → перебивает код-дефолт поставщика."""
monkeypatch.setattr(server, "_RECYCLE_PAGES_DEFAULT_BY_PROVIDER", {"domclick": 2})
fake_env = {"BROWSER_RECYCLE_PAGES": "7"}
assert server._resolve_recycle_pages("domclick", fake_env) == 7
@ -46,10 +50,11 @@ def test_invalid_per_provider_env_falls_back() -> None:
assert server._resolve_recycle_pages("cian", fake_env) == 9
def test_invalid_global_env_falls_back_to_code_default() -> None:
def test_invalid_global_env_falls_back_to_code_default(monkeypatch) -> None:
"""Невалидный глобальный env → фолбэк на код-дефолт per-provider (без исключения)."""
monkeypatch.setattr(server, "_RECYCLE_PAGES_DEFAULT_BY_PROVIDER", {"domclick": 2})
fake_env = {"BROWSER_RECYCLE_PAGES": "not-a-number"}
assert server._resolve_recycle_pages("domclick", fake_env) == 1
assert server._resolve_recycle_pages("domclick", fake_env) == 2
assert server._resolve_recycle_pages("cian", fake_env) == 15
@ -59,14 +64,26 @@ def test_value_below_one_clamped_to_one() -> None:
assert server._resolve_recycle_pages("cian", {"BROWSER_RECYCLE_PAGES": "-5"}) == 1
def test_domclick_without_env_defaults_to_one() -> None:
"""#3205: без единого env — domclick=1 (одна карточка на процесс camoufox)."""
assert server._resolve_recycle_pages("domclick", {}) == 1
def test_code_default_overrides_fallback(monkeypatch) -> None:
"""Код-дефолт поставщика (когда он есть) сильнее общего фолбэка 15."""
monkeypatch.setattr(server, "_RECYCLE_PAGES_DEFAULT_BY_PROVIDER", {"domclick": 2})
assert server._resolve_recycle_pages("domclick", {}) == 2
assert server._resolve_recycle_pages("cian", {}) == 15
def test_other_providers_without_env_default_to_fifteen() -> None:
"""avito/cian/yandex без env — общий фолбэк 15, узор бана у них не менялся."""
for provider in ("avito", "cian", "yandex"):
def test_domclick_is_not_restarted_per_page() -> None:
"""#3212: у domclick НЕТ код-дефолта 1 — перезапуск на карточку уничтожал пропуск.
Замер (прод, прод-прокси, 6 карточек): общий контекст без перезапуска 6/6;
новый контекст на карточку 1/6. Порог 1 гарантировал второе, а не первое.
"""
assert "domclick" not in server._RECYCLE_PAGES_DEFAULT_BY_PROVIDER
assert server._resolve_recycle_pages("domclick", {}) == 15
def test_all_providers_without_env_default_to_fifteen() -> None:
"""Без env все поставщики живут на общем фолбэке 15."""
for provider in ("avito", "cian", "yandex", "domclick"):
assert server._resolve_recycle_pages(provider, {}) == 15