Обогащение Авито берёт карточки в свежем контексте браузера #3596

Merged
lekss361 merged 1 commit from fix/avito-detail-fresh-context into main 2026-09-19 20:55:50 +00:00
4 changed files with 183 additions and 33 deletions
Showing only changes of commit 65e44c9195 - Show all commits

View file

@ -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

View file

@ -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(

View file

@ -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 НЕ звать.

View file

@ -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