fix(avito): карточки берутся в свежем контексте браузера, а не в общем
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s
Обогащение держало один контекст браузера на весь прогон (reuse_context=True). Авито закрывает карточки проверкой работой на JS, а её куки pow_challenge и pow_solved живут около пяти минут. Прогон живёт десятки минут, токен протухает и в живом контексте не обновляется, поэтому после первых карточек каждая следующая получает бан-страницу, и прогон рвёт защита по доле блокировок. Замер на проде 19.09, отдельная копия сайдкара, один и тот же прокси, 16 карточек на плечо, схема A-B-A: свежий контекст 14/16 и 13/16 успехов, 8.7 и 7.9 с на карточку общий контекст 6/16 успехов, 8.4 с на карточку Времени свежий контекст не стоит, а успешных карточек даёт вдвое больше. В плече с общим контекстом отказы нарастают по ходу: первые три успешны, последние пять подряд забанены. Переиспользование контекста пришло из #3118, где оно нужно ДомКлику, чтобы не терять куку его защиты. Для ДомКлика это остаётся верным, на Авито было перенесено по аналогии и замером опровергнуто. Поведение вынесено в AVITO_DETAIL_REUSE_CONTEXT с дефолтом false, чтобы вернуть старый режим без выката.
This commit is contained in:
parent
baed8f75a3
commit
65e44c9195
4 changed files with 183 additions and 33 deletions
|
|
@ -1066,6 +1066,26 @@ class Settings(BaseSettings):
|
|||
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
|
||||
)
|
||||
|
||||
# reuse_context для BrowserFetcher в avito_detail_backfill (browser-режим).
|
||||
# ENV: AVITO_DETAIL_REUSE_CONTEXT.
|
||||
#
|
||||
# Дефолт False (замер 19.09.2026, probe-browser, прокси узел 14, схема A-B-A,
|
||||
# 16 карточек ЕКБ на плечо): A1 (новый изолированный context на карточку) 14/16,
|
||||
# B (reuse_context=True + origin/referer) 6/16, A2 (снова изолированный) 13/16.
|
||||
# В плече B отказы нарастали по ходу — карточки 1-3 успешны, последние пять
|
||||
# подряд бан: выгорание контекста, а не совпадение. Живые куки pow_challenge/
|
||||
# pow_solved у Авито держатся ~5 минут (замер: выданы 23:08, истекли 23:13),
|
||||
# а прогон обогащения идёт десятки минут с паузой 5-6с между карточками — в
|
||||
# переиспользуемом context'е токен протухает и не обновляется, дальше каждая
|
||||
# карточка получает бан-страницу. В свежем context'е навигация решает PoW
|
||||
# заново. Решение про reuse_context=True перенесено на Авито по аналогии с
|
||||
# DomClick (#3118, QRATOR-PoW, 26/26 блоков на изолированных контекстах против
|
||||
# 5/5 успехов в тёплом) — для DomClick это по-прежнему верно, для Авито
|
||||
# опровергнуто измерением. Флаг — аварийный откат без выката кода.
|
||||
avito_detail_reuse_context: bool = Field(
|
||||
default=False, validation_alias="AVITO_DETAIL_REUSE_CONTEXT"
|
||||
)
|
||||
|
||||
# #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
|
||||
# fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда →
|
||||
# budget-guard (раз в итерацию) не срабатывает → heartbeat не обновляется → run
|
||||
|
|
|
|||
|
|
@ -411,13 +411,22 @@ async def run_avito_detail_backfill(
|
|||
# Замер 27.08: прогон 5098 — mode=browser, proxy_lease_id=None,
|
||||
# 5 блоков подряд из 5 попыток, enriched=0. При этом пул здоров
|
||||
# (4 узла, все ok), а тот же URL через прокси отдаёт 200 и 3.3 МБ.
|
||||
# reuse_context=True (#3180/#3251): без него sidecar's browser.new_page()
|
||||
# создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch — пройденный
|
||||
# QRATOR-PoW предыдущей карточки выбрасывается, и следующий запрос снова
|
||||
# холодный. У DomClick (#3118) это измеренно давало 100% блоков (26/26)
|
||||
# на изолированных контекстах против 5/5 успехов в тёплом контексте.
|
||||
# Сброс сожжённого context'а — ниже, через bf.request_context_reset()
|
||||
# (#3212: один раз за прогон, не на каждый блок — см. except-ветку).
|
||||
#
|
||||
# reuse_context управляется settings.avito_detail_reuse_context, дефолт
|
||||
# False (#3180/#3251 -> опровергнуто новым замером, см. config.py).
|
||||
# Изначально сюда перенесли reuse_context=True по аналогии с DomClick
|
||||
# (#3118, QRATOR-PoW): на изолированных контекстах 26/26 блоков против
|
||||
# 5/5 успехов в тёплом — для DomClick это по-прежнему верно и не
|
||||
# менялось. Для Авито замер 19.09.2026 (probe-browser, прокси узел 14,
|
||||
# схема A-B-A, 16 карточек ЕКБ на плечо) дал обратный результат:
|
||||
# изолированный context 14/16 и 13/16, тёплый reuse_context=True 6/16
|
||||
# с нарастающими отказами к концу плеча. Причина — куки Авито
|
||||
# pow_challenge/pow_solved живут ~5 минут, а прогон между карточками
|
||||
# растянут на десятки минут: в тёплом context'е токен протухает и не
|
||||
# обновляется, свежая навигация решает PoW заново. Сброс сожжённого
|
||||
# context'а (актуален только при reuse_context=True) — ниже, через
|
||||
# bf.request_context_reset() (#3212: один раз за прогон, не на каждый
|
||||
# блок — см. except-ветку).
|
||||
_cfg = RealScraperConfig()
|
||||
browser_fetcher = BrowserFetcher(
|
||||
source="avito",
|
||||
|
|
@ -427,7 +436,7 @@ async def run_avito_detail_backfill(
|
|||
# Без environment отказ «пул пуст» на этом пути мёртв — фетчер
|
||||
# молча ушёл бы на env-прокси сайдкара (#2616 шаг 1).
|
||||
environment=_cfg.environment,
|
||||
reuse_context=True,
|
||||
reuse_context=settings.avito_detail_reuse_context,
|
||||
)
|
||||
await browser_fetcher.__aenter__()
|
||||
own_browser = True
|
||||
|
|
@ -576,12 +585,15 @@ async def run_avito_detail_backfill(
|
|||
# прокси. Обнуляется на каждой ротации (успешной ИЛИ неуспешной — иначе
|
||||
# исчерпанный дневной лимит провайдера дёргал бы rotate_proxy на каждой попытке).
|
||||
attempts_since_rotation = 0
|
||||
# #3251: сброс переиспользуемого browser-context'а (reuse_context=True выше)
|
||||
# разрешён РОВНО один раз за прогон — зеркалит domclick_detail_backfill (#3212).
|
||||
# Сброс на КАЖДЫЙ блок сам себя поддерживает: пройденный QRATOR-PoW живёт в
|
||||
# context'е, сброс его выбрасывает, повторная проверка с того же IP сразу
|
||||
# после принятой снова блокируется — одна осечка превращается в необратимый
|
||||
# каскад блоков (см. except-ветку ниже).
|
||||
# #3251: сброс переиспользуемого browser-context'а разрешён РОВНО один раз
|
||||
# за прогон — зеркалит domclick_detail_backfill (#3212). Сброс на КАЖДЫЙ
|
||||
# блок сам себя поддерживает: пройденный QRATOR-PoW живёт в context'е,
|
||||
# сброс его выбрасывает, повторная проверка с того же IP сразу после
|
||||
# принятой снова блокируется — одна осечка превращается в необратимый
|
||||
# каскад блоков (см. except-ветку ниже). Осмыслен только при
|
||||
# settings.avito_detail_reuse_context=True — при выключенном
|
||||
# переиспользовании (дефолт) bare-reset ниже не вызывается вовсе: каждый
|
||||
# fetch и так идёт в свежем изолированном context'е.
|
||||
context_reset_used = False
|
||||
# #3283g: сколько раз ЗА ПРОГОН уже сработала ротация-на-бан (в отличие от
|
||||
# context_reset_used эта ротация меняет IP вместе со сбросом, поэтому не
|
||||
|
|
@ -901,7 +913,9 @@ async def run_avito_detail_backfill(
|
|||
):
|
||||
context_reset_used = True
|
||||
|
||||
if not context_reset_used:
|
||||
# Бессмысленно при reuse_context=False (дефолт): каждый fetch и
|
||||
# так идёт в свежем изолированном context'е, сбрасывать нечего.
|
||||
if settings.avito_detail_reuse_context and not context_reset_used:
|
||||
context_reset_used = True
|
||||
browser_fetcher.request_context_reset()
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
"""Тёплый переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251).
|
||||
"""Переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251/#3595).
|
||||
|
||||
Переносит на Avito рецепт, измеренно поднявший добор карточек у DomClick (#3118):
|
||||
sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch —
|
||||
пройденный QRATOR-подобный анти-бот проход площадки выбрасывается вместе с ним, и
|
||||
каждая следующая карточка снова холодная. reuse_context=True держит ОДИН sidecar-
|
||||
context живым на весь прогон; сброс сожжённого context'а оркестратор просит через
|
||||
bf.request_context_reset() — как и DomClick, ровно один раз за прогон (#3212), а не
|
||||
на каждый обнаруженный блок (иначе сброс сам себя поддерживает — см. domclick
|
||||
docstring). Reset относится ТОЛЬКО к реальному бану площадкой (AvitoBlockedError и
|
||||
подтипы), не к отказу нашего тракта (AvitoSidecarUnavailableError) и не к мёртвому
|
||||
листингу (AvitoListingGoneError) — площадка тут ни при чём в обоих случаях, context
|
||||
цел.
|
||||
Рецепт reuse_context=True изначально перенесли на Avito по аналогии с DomClick
|
||||
(#3118): sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на
|
||||
КАЖДЫЙ /fetch — пройденный анти-бот проход площадки выбрасывается вместе с ним, и
|
||||
каждая следующая карточка снова холодная. Для DomClick это подтверждено (26/26
|
||||
блоков на изолированных контекстах против 5/5 успехов в тёплом). Для Avito замер
|
||||
19.09.2026 (probe-browser, A-B-A, 16 карточек ЕКБ/плечо) дал обратный результат:
|
||||
изолированный context 14/16 и 13/16, reuse_context=True 6/16 с нарастающими
|
||||
отказами к концу плеча — 5-минутная PoW-кука протухает внутри растянутого на
|
||||
десятки минут прогона и не обновляется в тёплом context'е. Дефолт теперь
|
||||
settings.avito_detail_reuse_context=False; флаг остаётся аварийным откатом.
|
||||
|
||||
Когда reuse_context=True (явный override), сброс сожжённого context'а
|
||||
оркестратор просит через bf.request_context_reset() — как и DomClick, ровно один
|
||||
раз за прогон (#3212), а не на каждый обнаруженный блок (иначе сброс сам себя
|
||||
поддерживает — см. domclick docstring). Reset относится ТОЛЬКО к реальному бану
|
||||
площадкой (AvitoBlockedError и подтипы), не к отказу нашего тракта
|
||||
(AvitoSidecarUnavailableError) и не к мёртвому листингу (AvitoListingGoneError) —
|
||||
площадка тут ни при чём в обоих случаях, context цел. При reuse_context=False
|
||||
(дефолт) reset не вызывается вовсе — сбрасывать нечего.
|
||||
|
||||
Контракт тестов сформулирован от требования площадки/архитектуры (что ДОЛЖНО
|
||||
происходить при блоке/не-блоке), а не от текущей реализации — см. #3262, где
|
||||
|
|
@ -80,6 +88,9 @@ def _fake_settings(**overrides: object) -> MagicMock:
|
|||
# rotate-on-ban веткой -- она молчаливо остаётся в "gap не выдержан".
|
||||
"avito_detail_backfill_rotate_on_ban_max": 2,
|
||||
"avito_detail_backfill_rotate_on_ban_min_gap": 10,
|
||||
# Реальный дефолт (config.py, замер 19.09.2026) — изолированный context на
|
||||
# каждый fetch. Тесты, которым нужен тёплый context, переопределяют явно.
|
||||
"avito_detail_reuse_context": False,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return MagicMock(**defaults)
|
||||
|
|
@ -118,12 +129,17 @@ def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
|
|||
return MagicMock(return_value=instance), instance
|
||||
|
||||
|
||||
# ── BrowserFetcher(reuse_context=True) ──────────────────────────────────────────
|
||||
# ── BrowserFetcher(reuse_context=<settings.avito_detail_reuse_context>) ─────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
|
||||
"""BrowserFetcher поднимается с reuse_context=True (#3180) -- тёплый sidecar-context."""
|
||||
async def test_backfill_constructs_browser_fetcher_with_reuse_context_default_false() -> None:
|
||||
"""Дефолт settings.avito_detail_reuse_context=False -- fetcher идёт с ним же.
|
||||
|
||||
Замер 19.09.2026 (probe-browser, A-B-A, 16 карточек ЕКБ/плечо): изолированный
|
||||
context 14/16 и 13/16, reuse_context=True 6/16 с нарастающими отказами --
|
||||
выгорание ~5-минутной PoW-куки. См. config.py docstring.
|
||||
"""
|
||||
snapshot = _make_snapshot(1)
|
||||
db = _mock_db(snapshot)
|
||||
runs = MagicMock()
|
||||
|
|
@ -147,6 +163,37 @@ async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
|
|||
mock_bf_cls.assert_called_once()
|
||||
_, call_kwargs = mock_bf_cls.call_args
|
||||
assert call_kwargs["source"] == "avito"
|
||||
assert call_kwargs["reuse_context"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_forwards_reuse_context_from_settings_not_constant() -> None:
|
||||
"""reuse_context идёт из settings.avito_detail_reuse_context, не из хардкода.
|
||||
|
||||
Аварийный откат на тёплый context (settings=True) должен доходить до
|
||||
BrowserFetcher без правки кода.
|
||||
"""
|
||||
snapshot = _make_snapshot(1)
|
||||
db = _mock_db(snapshot)
|
||||
runs = MagicMock()
|
||||
mock_fetch = AsyncMock(return_value=MagicMock())
|
||||
fake_settings = _fake_settings(avito_detail_reuse_context=True)
|
||||
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
|
||||
with (
|
||||
patch(_SETTINGS, fake_settings),
|
||||
patch(_SESSION),
|
||||
patch(_SCRAPER),
|
||||
patch(_RUNS, runs),
|
||||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SAVE, return_value=True),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
):
|
||||
await run_avito_detail_backfill(
|
||||
db, run_id=209, params={"batch_size": 10, "budget_sec": 3600}
|
||||
)
|
||||
|
||||
_, call_kwargs = mock_bf_cls.call_args
|
||||
assert call_kwargs["reuse_context"] is True
|
||||
|
||||
|
||||
|
|
@ -155,18 +202,20 @@ async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_reset_requested_at_most_once_per_run() -> None:
|
||||
"""3 подряд AvitoBlockedError -> request_context_reset() запрошен ровно 1 раз.
|
||||
"""3 подряд AvitoBlockedError + reuse_context=True -> reset запрошен ровно 1 раз.
|
||||
|
||||
Зеркалит domclick #3212: сброс на КАЖДЫЙ блок сам себя поддерживает (пройденный
|
||||
анти-бот проход живёт в context'е, сброс его выбрасывает -> следующая проверка с
|
||||
того же IP снова блокируется) -- одна осечка становится необратимым каскадом.
|
||||
Актуально только при reuse_context=True -- при дефолтном False см.
|
||||
test_context_reset_not_requested_when_reuse_context_disabled ниже.
|
||||
"""
|
||||
snapshot = _make_snapshot(10)
|
||||
db = _mock_db(snapshot)
|
||||
runs = MagicMock()
|
||||
blocked_exc = AvitoBlockedError("firewall/soft-block")
|
||||
mock_fetch = AsyncMock(side_effect=blocked_exc)
|
||||
fake_settings = _fake_settings()
|
||||
fake_settings = _fake_settings(avito_detail_reuse_context=True)
|
||||
mock_bf_cls, instance = _mock_browser_fetcher_cls()
|
||||
with (
|
||||
patch(_SETTINGS, fake_settings),
|
||||
|
|
@ -196,7 +245,7 @@ async def test_content_blocked_subtype_also_requests_reset() -> None:
|
|||
db = _mock_db(snapshot)
|
||||
runs = MagicMock()
|
||||
mock_fetch = AsyncMock(side_effect=AvitoContentBlockedError("0 карточек, captcha"))
|
||||
fake_settings = _fake_settings()
|
||||
fake_settings = _fake_settings(avito_detail_reuse_context=True)
|
||||
mock_bf_cls, instance = _mock_browser_fetcher_cls()
|
||||
with (
|
||||
patch(_SETTINGS, fake_settings),
|
||||
|
|
@ -217,6 +266,40 @@ async def test_content_blocked_subtype_also_requests_reset() -> None:
|
|||
instance.request_context_reset.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_reset_not_requested_when_reuse_context_disabled() -> None:
|
||||
"""reuse_context=False (дефолт, замер 19.09.2026) -- bare reset не вызывается вовсе.
|
||||
|
||||
Каждый fetch уже идёт в свежем изолированном context'е -- сбрасывать нечего,
|
||||
даже когда блоки идут подряд. Отличает дефолт от test_context_reset_requested_
|
||||
at_most_once_per_run, где то же самое AvitoBlockedError с reuse_context=True
|
||||
даёт ровно один reset.
|
||||
"""
|
||||
snapshot = _make_snapshot(10)
|
||||
db = _mock_db(snapshot)
|
||||
runs = MagicMock()
|
||||
mock_fetch = AsyncMock(side_effect=AvitoBlockedError("firewall/soft-block"))
|
||||
fake_settings = _fake_settings()
|
||||
mock_bf_cls, instance = _mock_browser_fetcher_cls()
|
||||
with (
|
||||
patch(_SETTINGS, fake_settings),
|
||||
patch(_SESSION),
|
||||
patch(_SCRAPER),
|
||||
patch(_RUNS, runs),
|
||||
patch(_BROWSER_FETCHER, mock_bf_cls),
|
||||
patch(_FETCH, mock_fetch),
|
||||
patch(_SLEEP, new_callable=AsyncMock),
|
||||
):
|
||||
result = await run_avito_detail_backfill(
|
||||
db,
|
||||
run_id=210,
|
||||
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
|
||||
)
|
||||
|
||||
assert result.blocked == 3
|
||||
instance.request_context_reset.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sidecar_unavailable_does_not_request_context_reset() -> None:
|
||||
"""AvitoSidecarUnavailableError -- НАШ тракт, площадка ни при чём: reset НЕ звать.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""avito_detail_reuse_context — дефолт False (замер 19.09.2026, probe-browser).
|
||||
|
||||
Схема A-B-A, 16 карточек ЕКБ на плечо, один прокси-узел: изолированный context
|
||||
14/16 и 13/16, reuse_context=True (тёплый) 6/16 с нарастающими отказами к концу
|
||||
плеча — выгорание PoW-куки (~5 минут жизни) внутри растянутого на десятки минут
|
||||
прогона. Перенесено на Avito по аналогии с DomClick (#3118) и опровергнуто
|
||||
измерением; для DomClick reuse_context остаётся верным решением. Проверка по
|
||||
значению поля Settings без env и без .env-файла — зеркалит test_3045_use_curl_default.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
def test_avito_detail_reuse_context_defaults_to_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("AVITO_DETAIL_REUSE_CONTEXT", raising=False)
|
||||
|
||||
assert Settings(_env_file=None).avito_detail_reuse_context is False
|
||||
|
||||
|
||||
def test_env_still_switches_reuse_context_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Контроль: переменная окружения по-прежнему включает режим — поле живое.
|
||||
|
||||
Флаг остаётся аварийным откатом на тёплый context без выката кода.
|
||||
"""
|
||||
monkeypatch.setenv("AVITO_DETAIL_REUSE_CONTEXT", "true")
|
||||
|
||||
assert Settings(_env_file=None).avito_detail_reuse_context is True
|
||||
Loading…
Add table
Reference in a new issue