Обогащение Авито берёт карточки в свежем контексте браузера (#3596)
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m43s
Deploy Trade-In / build-backend (push) Successful in 2m58s
Deploy Trade-In / deploy (push) Successful in 8m8s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m41s

This commit is contained in:
lekss361 2026-09-19 20:55:49 +00:00
parent baed8f75a3
commit e811b6dfbc
4 changed files with 183 additions and 33 deletions

View file

@ -1066,6 +1066,26 @@ class Settings(BaseSettings):
default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" 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. Зависший # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
# fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда → # fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда →
# budget-guard (раз в итерацию) не срабатывает → heartbeat не обновляется → run # 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, # Замер 27.08: прогон 5098 — mode=browser, proxy_lease_id=None,
# 5 блоков подряд из 5 попыток, enriched=0. При этом пул здоров # 5 блоков подряд из 5 попыток, enriched=0. При этом пул здоров
# (4 узла, все ok), а тот же URL через прокси отдаёт 200 и 3.3 МБ. # (4 узла, все ok), а тот же URL через прокси отдаёт 200 и 3.3 МБ.
# reuse_context=True (#3180/#3251): без него sidecar's browser.new_page() #
# создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch — пройденный # reuse_context управляется settings.avito_detail_reuse_context, дефолт
# QRATOR-PoW предыдущей карточки выбрасывается, и следующий запрос снова # False (#3180/#3251 -> опровергнуто новым замером, см. config.py).
# холодный. У DomClick (#3118) это измеренно давало 100% блоков (26/26) # Изначально сюда перенесли reuse_context=True по аналогии с DomClick
# на изолированных контекстах против 5/5 успехов в тёплом контексте. # (#3118, QRATOR-PoW): на изолированных контекстах 26/26 блоков против
# Сброс сожжённого context'а — ниже, через bf.request_context_reset() # 5/5 успехов в тёплом — для DomClick это по-прежнему верно и не
# (#3212: один раз за прогон, не на каждый блок — см. except-ветку). # менялось. Для Авито замер 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() _cfg = RealScraperConfig()
browser_fetcher = BrowserFetcher( browser_fetcher = BrowserFetcher(
source="avito", source="avito",
@ -427,7 +436,7 @@ async def run_avito_detail_backfill(
# Без environment отказ «пул пуст» на этом пути мёртв — фетчер # Без environment отказ «пул пуст» на этом пути мёртв — фетчер
# молча ушёл бы на env-прокси сайдкара (#2616 шаг 1). # молча ушёл бы на env-прокси сайдкара (#2616 шаг 1).
environment=_cfg.environment, environment=_cfg.environment,
reuse_context=True, reuse_context=settings.avito_detail_reuse_context,
) )
await browser_fetcher.__aenter__() await browser_fetcher.__aenter__()
own_browser = True own_browser = True
@ -576,12 +585,15 @@ async def run_avito_detail_backfill(
# прокси. Обнуляется на каждой ротации (успешной ИЛИ неуспешной — иначе # прокси. Обнуляется на каждой ротации (успешной ИЛИ неуспешной — иначе
# исчерпанный дневной лимит провайдера дёргал бы rotate_proxy на каждой попытке). # исчерпанный дневной лимит провайдера дёргал бы rotate_proxy на каждой попытке).
attempts_since_rotation = 0 attempts_since_rotation = 0
# #3251: сброс переиспользуемого browser-context'а (reuse_context=True выше) # #3251: сброс переиспользуемого browser-context'а разрешён РОВНО один раз
# разрешён РОВНО один раз за прогон — зеркалит domclick_detail_backfill (#3212). # за прогон — зеркалит domclick_detail_backfill (#3212). Сброс на КАЖДЫЙ
# Сброс на КАЖДЫЙ блок сам себя поддерживает: пройденный QRATOR-PoW живёт в # блок сам себя поддерживает: пройденный QRATOR-PoW живёт в context'е,
# context'е, сброс его выбрасывает, повторная проверка с того же IP сразу # сброс его выбрасывает, повторная проверка с того же IP сразу после
# после принятой снова блокируется — одна осечка превращается в необратимый # принятой снова блокируется — одна осечка превращается в необратимый
# каскад блоков (см. except-ветку ниже). # каскад блоков (см. except-ветку ниже). Осмыслен только при
# settings.avito_detail_reuse_context=True — при выключенном
# переиспользовании (дефолт) bare-reset ниже не вызывается вовсе: каждый
# fetch и так идёт в свежем изолированном context'е.
context_reset_used = False context_reset_used = False
# #3283g: сколько раз ЗА ПРОГОН уже сработала ротация-на-бан (в отличие от # #3283g: сколько раз ЗА ПРОГОН уже сработала ротация-на-бан (в отличие от
# context_reset_used эта ротация меняет IP вместе со сбросом, поэтому не # context_reset_used эта ротация меняет IP вместе со сбросом, поэтому не
@ -901,7 +913,9 @@ async def run_avito_detail_backfill(
): ):
context_reset_used = True 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 context_reset_used = True
browser_fetcher.request_context_reset() browser_fetcher.request_context_reset()
logger.warning( logger.warning(

View file

@ -1,16 +1,24 @@
"""Тёплый переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251). """Переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251/#3595).
Переносит на Avito рецепт, измеренно поднявший добор карточек у DomClick (#3118): Рецепт reuse_context=True изначально перенесли на Avito по аналогии с DomClick
sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch — (#3118): sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на
пройденный QRATOR-подобный анти-бот проход площадки выбрасывается вместе с ним, и КАЖДЫЙ /fetch пройденный анти-бот проход площадки выбрасывается вместе с ним, и
каждая следующая карточка снова холодная. reuse_context=True держит ОДИН sidecar- каждая следующая карточка снова холодная. Для DomClick это подтверждено (26/26
context живым на весь прогон; сброс сожжённого context'а оркестратор просит через блоков на изолированных контекстах против 5/5 успехов в тёплом). Для Avito замер
bf.request_context_reset() как и DomClick, ровно один раз за прогон (#3212), а не 19.09.2026 (probe-browser, A-B-A, 16 карточек ЕКБ/плечо) дал обратный результат:
на каждый обнаруженный блок (иначе сброс сам себя поддерживает см. domclick изолированный context 14/16 и 13/16, reuse_context=True 6/16 с нарастающими
docstring). Reset относится ТОЛЬКО к реальному бану площадкой (AvitoBlockedError и отказами к концу плеча 5-минутная PoW-кука протухает внутри растянутого на
подтипы), не к отказу нашего тракта (AvitoSidecarUnavailableError) и не к мёртвому десятки минут прогона и не обновляется в тёплом context'е. Дефолт теперь
листингу (AvitoListingGoneError) площадка тут ни при чём в обоих случаях, 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, где происходить при блоке/не-блоке), а не от текущей реализации см. #3262, где
@ -80,6 +88,9 @@ def _fake_settings(**overrides: object) -> MagicMock:
# rotate-on-ban веткой -- она молчаливо остаётся в "gap не выдержан". # rotate-on-ban веткой -- она молчаливо остаётся в "gap не выдержан".
"avito_detail_backfill_rotate_on_ban_max": 2, "avito_detail_backfill_rotate_on_ban_max": 2,
"avito_detail_backfill_rotate_on_ban_min_gap": 10, "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) defaults.update(overrides)
return MagicMock(**defaults) return MagicMock(**defaults)
@ -118,12 +129,17 @@ def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
return MagicMock(return_value=instance), instance return MagicMock(return_value=instance), instance
# ── BrowserFetcher(reuse_context=True) ────────────────────────────────────────── # ── BrowserFetcher(reuse_context=<settings.avito_detail_reuse_context>) ─────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None: async def test_backfill_constructs_browser_fetcher_with_reuse_context_default_false() -> None:
"""BrowserFetcher поднимается с reuse_context=True (#3180) -- тёплый sidecar-context.""" """Дефолт 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) snapshot = _make_snapshot(1)
db = _mock_db(snapshot) db = _mock_db(snapshot)
runs = MagicMock() runs = MagicMock()
@ -147,6 +163,37 @@ async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
mock_bf_cls.assert_called_once() mock_bf_cls.assert_called_once()
_, call_kwargs = mock_bf_cls.call_args _, call_kwargs = mock_bf_cls.call_args
assert call_kwargs["source"] == "avito" 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 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 @pytest.mark.asyncio
async def test_context_reset_requested_at_most_once_per_run() -> None: 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: сброс на КАЖДЫЙ блок сам себя поддерживает (пройденный Зеркалит domclick #3212: сброс на КАЖДЫЙ блок сам себя поддерживает (пройденный
анти-бот проход живёт в context'е, сброс его выбрасывает -> следующая проверка с анти-бот проход живёт в context'е, сброс его выбрасывает -> следующая проверка с
того же IP снова блокируется) -- одна осечка становится необратимым каскадом. того же IP снова блокируется) -- одна осечка становится необратимым каскадом.
Актуально только при reuse_context=True -- при дефолтном False см.
test_context_reset_not_requested_when_reuse_context_disabled ниже.
""" """
snapshot = _make_snapshot(10) snapshot = _make_snapshot(10)
db = _mock_db(snapshot) db = _mock_db(snapshot)
runs = MagicMock() runs = MagicMock()
blocked_exc = AvitoBlockedError("firewall/soft-block") blocked_exc = AvitoBlockedError("firewall/soft-block")
mock_fetch = AsyncMock(side_effect=blocked_exc) 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() mock_bf_cls, instance = _mock_browser_fetcher_cls()
with ( with (
patch(_SETTINGS, fake_settings), patch(_SETTINGS, fake_settings),
@ -196,7 +245,7 @@ async def test_content_blocked_subtype_also_requests_reset() -> None:
db = _mock_db(snapshot) db = _mock_db(snapshot)
runs = MagicMock() runs = MagicMock()
mock_fetch = AsyncMock(side_effect=AvitoContentBlockedError("0 карточек, captcha")) 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() mock_bf_cls, instance = _mock_browser_fetcher_cls()
with ( with (
patch(_SETTINGS, fake_settings), 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() 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 @pytest.mark.asyncio
async def test_sidecar_unavailable_does_not_request_context_reset() -> None: async def test_sidecar_unavailable_does_not_request_context_reset() -> None:
"""AvitoSidecarUnavailableError -- НАШ тракт, площадка ни при чём: reset НЕ звать. """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