From 09bdd7888b7655d12a757238f950d0ab33bf2ba9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 14:09:23 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/domclick):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=B0=20=D0=BD=D0=B0=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D1=83=20=D1=83=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D1=82=D0=BE=D0=B6=D0=B0=D0=BB=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=20QRATOR=20(#3212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ДомКлик закрыт QRATOR с proof-of-work: первый запрос отдаёт 401 и заглушку с задачей, браузер её решает, дёргает /__qrator/validate и получает пропуск в куках (qrator_jsid2 + qrator_jsr). Пропуск живёт в cookie jar, то есть в browser context'е — а прод брал каждую карточку в новом контексте, выбрасывая его. Повторная валидация с того же IP получала 403 и страницу bot-mitigation. Замер (прод, прод-прокси, по 6 карточек): общий контекст, одна вкладка 6/6, validate не вызывался ни разу общий контекст, вкладка на карточку 6/6, validate не вызывался ни разу новый контекст на карточку (= прод) 1/6, validate = [304, 403] на каждой боевой путь /fetch с reuse_context 6/6 при 0 перезапусков и 1 контексте Что правится: * снят код-дефолт domclick=1 из #3205: перезапуск процесса гарантированно уничтожает контекст, то есть лечил симптом, который сам же и создавал. Ручка per-provider и domclick как отдельный провайдер остаются; * сброс контекста в бэкфилле был на КАЖДЫЙ блок — стал один раз за прогон. Это и объясняет провал #3193: сброс выбрасывал пропуск, следующий фетч блокировался гарантированно, что снова вызывало сброс. Приёмка тогда дала ровно 1 успех из 10; * снята неверная формулировка «отказ, а не челлендж» из #3204/#3205 — 26 624 байта это РЕЗУЛЬТАТ проваленного PoW, а не статика вместо него. Ошибка вышла из метода: HTML читали на 4.5-й секунде и не смотрели в сеть. Замер 6/6, которым обосновывали #3205, был испорчен: в логах сайдкара после каждой страницы стоит «recycle threshold (1) достигнут, перезапуск браузера». Тесты: два кодировали domclick=1 — переписаны через подставной словарь, чтобы уровень «код-дефолт поставщика» продолжал проверяться, а не исчез вместе с записью. Тест сброса требует ровно одну попытку за прогон. 159 passed (сайдкар), 4972 passed / 37 skipped (backend). --- .../app/tasks/domclick_detail_backfill.py | 40 ++++++++++---- .../tests/test_3118_domclick_warm_context.py | 29 ++++++---- tradein-mvp/browser/server.py | 55 ++++++++----------- tradein-mvp/browser/test_server_recycle.py | 41 ++++++++++---- 4 files changed, 98 insertions(+), 67 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index a81ea102..23804881 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -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", diff --git a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py index dc4559e4..5827345a 100644 --- a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py +++ b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py @@ -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, ( + "ожидалась ровно одна попытка начать с чистого листа за весь прогон" ) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 706153eb..56b20e29 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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 diff --git a/tradein-mvp/browser/test_server_recycle.py b/tradein-mvp/browser/test_server_recycle.py index 14e36d99..62bc6ac0 100644 --- a/tradein-mvp/browser/test_server_recycle.py +++ b/tradein-mvp/browser/test_server_recycle.py @@ -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 -- 2.45.3