Compare commits

..

No commits in common. "fbc8e89b6fb1239decb0cf3591b45b8dcc713c74" and "a3e642fed4fbab620f49778b650cd3a46e292abd" have entirely different histories.

4 changed files with 67 additions and 98 deletions

View file

@ -54,8 +54,9 @@ 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' (ровно тот случай,
`403 | Домклик` на 26 624 байта, снятая вживую 28.08), 5xx 'infra', статуса нет что намеряли вживую 28.08: статическая страница `403 | Домклик` на 26 624 байта,
без 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
@ -163,18 +164,11 @@ def _warn_before_domclick_cookies_expire(db: Session, run_id: int) -> None:
) )
# 401 у ДомКлика — отказ площадки, а не «наша сессия протухла» (замер прод 2026-08-29): # 401 у ДомКлика — тот же отказ площадки, что 403 (замер прод 2026-08-29): страница отказа
# страница при 401 РОВНО 26 624 байта, байт в байт совпадает со страницей `403 | Домклик` # при 401 РОВНО 26 624 байта, байт в байт совпадает со страницей `403 | Домклик`
# (снята 28.08.2026). Приходит одинаково и с валидной сохранённой сессией (16 куков # (снята 28.08.2026), без PoW/QRATOR/капчи. Приходит одинаково и с валидной сохранённой
# domclick_session), и полностью анонимно — значит дело не в сессии, а в вердикте WAF. # сессией (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): у других # Правка ДОМЕННАЯ, не в общей ban_kind_from_status (scraper_kit.browser_fetcher): у других
# поставщиков 401 обычно значит «наша сессия протухла» (наша сторона) — общая метка # поставщиков 401 обычно значит «наша сессия протухла» (наша сторона) — общая метка
# 'platform' там зря запустила бы ротацию IP (#2611). # 'platform' там зря запустила бы ротацию IP (#2611).
@ -304,9 +298,6 @@ 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 -> сколько раз);
@ -386,17 +377,10 @@ 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 просил сброс на КАЖДЫЙ блок — и этим сам себя блокировал. # #3118: сожжённый переиспользуемый context (протухший qrator_jsid2
# Пропуск QRATOR (куки qrator_jsid2 + qrator_jsr) живёт в context'е; # / стухшая сессия) не должен тянуться дальше по прогону — просим
# сброс его выбрасывает, а повторная валидация с того же IP сразу # ОДИН сброс на следующем fetch(), не на каждый последующий запрос.
# после принятой получает 403. То есть сброс на каждый блок делает bf.request_context_reset()
# из одной осечки необратимый каскад: блок → сброс → гарантированный
# блок → сброс… Ровно это и дало приёмке #3118 «1 успех из 10».
# #3212: одна попытка начать с чистого листа за прогон — на случай
# действительно протухшей сессии (TTL пропуска ~2.5ч) — и всё.
if not context_reset_used:
context_reset_used = True
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 "
"(consecutive=%d, http=%s, kind=%s): %s", "(consecutive=%d, http=%s, kind=%s): %s",

View file

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

View file

@ -89,13 +89,21 @@ 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 (сейчас
(_RECYCLE_PAGES_DEFAULT_BY_PROVIDER, сейчас пуст) только domclick=1) общий фолбэк 15. Живой замер
общий фолбэк 15. Ручка оставлена для будущих исключений; 2026-08-29 (#3205, прод): ДомКлик отдаёт РОВНО одну
заведённый в #3205 domclick=1 снят в #3212 — перезапуск карточку на процесс camoufox три независимых прогона,
процесса уничтожает browser context, а в нём лежит пропуск карточка #1 всегда 200 (~1МБ SSR-стейт), карточка #2
QRATOR, ради которого всё и затевалось (подробности у всегда 401 (~26625 байт страницы отказа); A/B на восьми
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER). карточках свой браузер на карточку 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 нет.
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
@ -182,9 +190,9 @@ FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15
# Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий # Известные поставщики. "generic" — фолбэк для всех прочих хостов (один общий
# инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод. # инстанс на неузнанные домены). Порядок задаёт детерминированный health-вывод.
# "domclick" (#3205): собственный инстанс — у площадки свой антибот (QRATOR с # "domclick" (#3205): собственный инстанс — площадка отдаёт РОВНО одну карточку на
# proof-of-work) и свой ритм, и держать её на общем с generic счётчике страниц и # процесс camoufox (см. _RECYCLE_PAGES_BY_PROVIDER ниже), общий с generic счётчик
# общем браузере значило бы мешать её сессию с чужими запросами. # страниц пачкал бы чужие запросы чужим порогом перезапуска.
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick") PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick")
@ -312,20 +320,19 @@ 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 ПО ПРОВАЙДЕРУ. Пуст: сейчас все поставщики живут на общем # Код-дефолт recycle_pages ПО ПРОВАЙДЕРУ (#3205). ДомКлик отдаёт РОВНО одну карточку на
# фолбэке, ручка per-provider (env + этот словарь) остаётся для будущих исключений. # процесс camoufox — вторая и далее получают 401-страницу отказа (~26625 байт) вместо
# # ~1МБ карточки с SSR-стейтом. Признак живёт на уровне ПРОЦЕССА (camoufox генерирует
# #3205 заводил здесь domclick=1 — перезапуск процесса camoufox после КАЖДОЙ карточки. # отпечаток при запуске, не при создании context'а): в замере browser.new_page() на
# #3212 это снял: перезапуск уничтожает browser context, а в контексте лежит пропуск # каждую карточку (свежий изолированный context) отказ со второй карточки всё равно
# QRATOR (куки qrator_jsid2 + qrator_jsr), который площадка выдаёт после принятой # случался — значит reuse_context/reset_context (#3118) эту задачу НЕ решает, нужен
# валидации /__qrator/validate. То есть #3205 боролся с симптомом, который сам же и # новый процесс, а не новый context. Живой замер 2026-08-29 (#3205): три независимых
# создавал. Замер (прод, прод-прокси, по 6 карточек на условие): общий контекст — 6/6 # прогона через camoufox+прод-прокси — карточка #1 всегда 200, карточка #2 всегда 401;
# и НИ ОДНОГО повторного вызова validate; новый контекст на карточку — 1/6, и на каждой # A/B на восьми карточках — свой браузер на каждую 4/4 успеха, один браузер на четыре
# следующей validate отвечает 403. Вкладки роли не играют, играет общий cookie jar. # 1/4. Цена перезапуска процесса ~0.6с (первый холодный запуск ~3.5с) — дёшево против
# Рассуждение #3205 «признак живёт на уровне процесса, свежий контекст отказ не снимает» # цены отказа. avito/cian/yandex НЕ трогаем — узор бана у них другой, отдельного замера
# было верным наблюдением с неверным выводом: свежий контекст его действительно не # под 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,11 +5,8 @@
2. глобальный env перебивает код-дефолт per-provider; 2. глобальный env перебивает код-дефолт per-provider;
3. невалидное (не int) значение фолбэк, без исключения; 3. невалидное (не int) значение фолбэк, без исключения;
4. значение <1 приводится к 1; 4. значение <1 приводится к 1;
5. код-дефолт per-provider перебивает общий фолбэк (#3212: словарь 5. domclick без env код-дефолт 1 (живой замер #3205: одна карточка на процесс);
_RECYCLE_PAGES_DEFAULT_BY_PROVIDER сейчас ПУСТ domclick=1 снят, потому что 6. avito/cian/yandex без env общий фолбэк 15 (их узор бана не менялся).
перезапуск процесса уничтожал пропуск QRATOR; уровень приоритета остался и
проверяется через подставной словарь, а не через живого поставщика);
6. все поставщики без env общий фолбэк 15.
camoufox/Playwright НЕ поднимается: тестируется чистая функция резолвера. camoufox/Playwright НЕ поднимается: тестируется чистая функция резолвера.
@ -37,9 +34,8 @@ 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(monkeypatch) -> None: def test_global_env_overrides_code_default() -> None:
"""Нет per-provider env, но задан глобальный → перебивает код-дефолт поставщика.""" """Нет per-provider env, но задан глобальный → перебивает код-дефолт domclick=1."""
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
@ -50,11 +46,10 @@ 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(monkeypatch) -> None: def test_invalid_global_env_falls_back_to_code_default() -> 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) == 2 assert server._resolve_recycle_pages("domclick", fake_env) == 1
assert server._resolve_recycle_pages("cian", fake_env) == 15 assert server._resolve_recycle_pages("cian", fake_env) == 15
@ -64,26 +59,14 @@ 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_code_default_overrides_fallback(monkeypatch) -> None: def test_domclick_without_env_defaults_to_one() -> None:
"""Код-дефолт поставщика (когда он есть) сильнее общего фолбэка 15.""" """#3205: без единого env — domclick=1 (одна карточка на процесс camoufox)."""
monkeypatch.setattr(server, "_RECYCLE_PAGES_DEFAULT_BY_PROVIDER", {"domclick": 2}) assert server._resolve_recycle_pages("domclick", {}) == 1
assert server._resolve_recycle_pages("domclick", {}) == 2
assert server._resolve_recycle_pages("cian", {}) == 15
def test_domclick_is_not_restarted_per_page() -> None: def test_other_providers_without_env_default_to_fifteen() -> None:
"""#3212: у domclick НЕТ код-дефолта 1 — перезапуск на карточку уничтожал пропуск. """avito/cian/yandex без env — общий фолбэк 15, узор бана у них не менялся."""
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