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
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:
commit
fbc8e89b6f
4 changed files with 98 additions and 67 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, (
|
||||
"ожидалась ровно одна попытка начать с чистого листа за весь прогон"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue