fix(tradein/domclick): перезапуск браузера на карточку уничтожал пропуск QRATOR (#3212) #3217

Merged
lekss361 merged 1 commit from fix/3212-domclick-keep-context into main 2026-08-29 11:16:40 +00:00
4 changed files with 98 additions and 67 deletions

View file

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

View file

@ -7,11 +7,14 @@
карточки в тёплом browser-контексте = 5/5 успешно. карточки в тёплом browser-контексте = 5/5 успешно.
Фикс: BrowserFetcher(reuse_context=True) держит per-provider context живым между Фикс: BrowserFetcher(reuse_context=True) держит per-provider context живым между
вызовами; куки вливаются один раз при его создании. При обнаруженном блоке следующий вызовами; куки вливаются один раз при его создании. Сброс сожжённого context'а
fetch должен сбросить сожжённый context так как fetch_detail() (scraper_kit, оркестратор просит через bf.request_context_reset() так как fetch_detail()
намеренно не в scope этого фикса) не прокидывает reset_context, оркестратор сигналит (scraper_kit, намеренно не в scope того фикса) не прокидывает reset_context.
через bf.request_context_reset() (BrowserFetcher: отложенный флаг, потребляется РОВНО
одним следующим fetch()). #3212 сузил условие сброса: НЕ на каждый блок, а один раз за прогон. Пропуск QRATOR
(qrator_jsid2 + qrator_jsr) живёт в контексте, и сброс его выбрасывает; повторная
валидация с того же IP сразу после принятой получает 403. Сброс на каждый блок поэтому
гарантировал следующий блок приёмка #3118 и дала «1 успех из 10».
Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers). 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 @pytest.mark.asyncio
async def test_block_triggers_exactly_one_context_reset_request() -> None: async def test_context_reset_requested_at_most_once_per_run() -> None:
"""DomClickBlockedError -> bf.request_context_reset() вызывается РОВНО один раз на блок. """#3212: сброс контекста запрашивается РОВНО ОДИН раз за прогон, а не на каждый блок.
3 подряд блока (max_consecutive_blocks=3) -> сброс запрашивается 3 раза (по разу До #3212 сброс шёл на КАЖДЫЙ блок, и это само себя поддерживало: в контексте лежит
НА КАЖДЫЙ обнаруженный блок), а не один раз на весь прогон и не на каждый успешный пропуск QRATOR, сброс его выбрасывает, повторная валидация с того же IP сразу после
fetch между блоками. принятой получает 403 то есть следующий фетч блокировался гарантированно. Одна
осечка превращалась в необратимый каскад (приёмка #3118: 1 успех из 10).
Здесь 3 подряд блока -> сброс запрошен ровно 1 раз.
""" """
snapshot = _make_snapshot(10) snapshot = _make_snapshot(10)
db = _mock_db(snapshot) db = _mock_db(snapshot)
@ -139,8 +144,8 @@ async def test_block_triggers_exactly_one_context_reset_request() -> None:
) )
assert result.blocked == 3 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). BROWSER_RECYCLE_PAGES_{PROVIDER} per-provider override recycle-порога (#3205).
PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC, PROVIDER в upper-case: AVITO, CIAN, YANDEX, GENERIC,
DOMCLICK. Приоритет: per-provider env глобальный DOMCLICK. Приоритет: per-provider env глобальный
BROWSER_RECYCLE_PAGES код-дефолт per-provider (сейчас BROWSER_RECYCLE_PAGES код-дефолт per-provider
только domclick=1) общий фолбэк 15. Живой замер (_RECYCLE_PAGES_DEFAULT_BY_PROVIDER, сейчас пуст)
2026-08-29 (#3205, прод): ДомКлик отдаёт РОВНО одну общий фолбэк 15. Ручка оставлена для будущих исключений;
карточку на процесс camoufox три независимых прогона, заведённый в #3205 domclick=1 снят в #3212 — перезапуск
карточка #1 всегда 200 (~1МБ SSR-стейт), карточка #2 процесса уничтожает browser context, а в нём лежит пропуск
всегда 401 (~26625 байт страницы отказа); A/B на восьми QRATOR, ради которого всё и затевалось (подробности у
карточках свой браузер на карточку 4/4 успеха, один _RECYCLE_PAGES_DEFAULT_BY_PROVIDER).
браузер на четыре 1/4. Признак живёт на уровне ПРОЦЕССА
(camoufox генерирует отпечаток при запуске, а не при
создании context'а) — свежий context (browser.new_page())
отказ со второй карточки НЕ снимает, значит
reset_context/reuse_context (#3118) эту проблему не решают:
нужен новый процесс. Цена перезапуска ~0.6с (первый
холодный запуск ~3.5с). avito/cian/yandex остаются на 15
свой узор бана, отдельного замера под recycle=1 нет.
SCRAPER_PROXY_URL http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/ SCRAPER_PROXY_URL http-прокси, ОБЩИЙ для всех поставщиков (avito/cian/
yandex/generic). #2616 шаг 2: per-provider yandex/generic). #2616 шаг 2: per-provider
BROWSER_PROXY_AVITO/CIAN/YANDEX и legacy 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" — фолбэк для всех прочих хостов (один общий # Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод. # инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
# "domclick" (#3205): собственный инстанс — площадка отдаёт РОВНО одну карточку на # "domclick" (#3205): собственный инстанс — у площадки свой антибот (QRATOR с
# процесс camoufox (см. _RECYCLE_PAGES_BY_PROVIDER ниже), общий с generic счётчик # proof-of-work) и свой ритм, и держать её на общем с generic счётчике страниц и
# страниц пачкал бы чужие запросы чужим порогом перезапуска. # общем браузере значило бы мешать её сессию с чужими запросами.
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick") PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick")
@ -320,19 +312,20 @@ def _resolve_block_images(
# Читается в _launch_browser; логируется на старте (_on_startup). # Читается в _launch_browser; логируется на старте (_on_startup).
_BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = {p: _resolve_block_images(p) for p in PROVIDERS} _BLOCK_IMAGES_BY_PROVIDER: dict[str, bool] = {p: _resolve_block_images(p) for p in PROVIDERS}
# Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ (#3205). ДомКлик отдаёт РОВНО одну карточку на # Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ. Пуст: сейчас все поставщики живут на общем
# процесс camoufox — вторая и далее получают 401-страницу отказа (~26625 байт) вместо # фолбэке, ручка per-provider (env + этот словарь) остаётся для будущих исключений.
# ~1МБ карточки с SSR-стейтом. Признак живёт на уровне ПРОЦЕССА (camoufox генерирует #
# отпечаток при запуске, не при создании context'а): в замере browser.new_page() на # #3205 заводил здесь domclick=1 — перезапуск процесса camoufox после КАЖДОЙ карточки.
# каждую карточку (свежий изолированный context) отказ со второй карточки всё равно # #3212 это снял: перезапуск уничтожает browser context, а в контексте лежит пропуск
# случался — значит reuse_context/reset_context (#3118) эту задачу НЕ решает, нужен # QRATOR (куки qrator_jsid2 + qrator_jsr), который площадка выдаёт после принятой
# новый процесс, а не новый context. Живой замер 2026-08-29 (#3205): три независимых # валидации /__qrator/validate. То есть #3205 боролся с симптомом, который сам же и
# прогона через camoufox+прод-прокси — карточка #1 всегда 200, карточка #2 всегда 401; # создавал. Замер (прод, прод-прокси, по 6 карточек на условие): общий контекст — 6/6
# A/B на восьми карточках — свой браузер на каждую 4/4 успеха, один браузер на четыре # и НИ ОДНОГО повторного вызова validate; новый контекст на карточку — 1/6, и на каждой
# 1/4. Цена перезапуска процесса ~0.6с (первый холодный запуск ~3.5с) — дёшево против # следующей validate отвечает 403. Вкладки роли не играют, играет общий cookie jar.
# цены отказа. avito/cian/yandex НЕ трогаем — узор бана у них другой, отдельного замера # Рассуждение #3205 «признак живёт на уровне процесса, свежий контекст отказ не снимает»
# под recycle=1 нет, дефолт 15 остаётся. # было верным наблюдением с неверным выводом: свежий контекст его действительно не
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {"domclick": 1} # снимает — потому что он и есть причина.
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER: dict[str, int] = {}
_RECYCLE_PAGES_DEFAULT_FALLBACK: int = 15 _RECYCLE_PAGES_DEFAULT_FALLBACK: int = 15

View file

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