fix(tradein/domclick): каждый запрос получал чистый браузерный контекст, куки реплеились протухшими
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
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 / browser-tests (pull_request) Successful in 1m7s
CI Trade-In / backend-tests (pull_request) Successful in 4m49s

Сайдкар на каждый /fetch делал browser.new_page() поверх AsyncCamoufox — это новый
изолированный контекст, cookie-jar умирал сразу после ответа. Туда вливался
замороженный снимок кук из domclick_session_cookies, где qrator_jsid2 живёт ~2.5
часа, а хранится 30 дней. Первый запрос проходил с ещё живым токеном, все следующие
показывали QRATOR один и тот же протухший — он отдавал челлендж-страницу без
__SSR_STATE__. Отсюда двухнедельное attempted=4, enriched=1, blocked=3.

Замер на проде 28.08: через сайдкар 26 запросов подряд — 100% блоков, включая
свежеротированный exit-IP; те же карточки в тёплом контексте — 5 из 5 успешно,
~2 сек каждая.

Сайдкар получил переиспользуемый per-provider контекст: куки вливаются один раз при
создании, дальше jar живёт сам; страница закрывается после ответа, контекст остаётся.
Закрытие контекста подшито к _close_browser, так что relaunch и shutdown его не
теряют. Флаг opt-in: при reuse_context=False payload /fetch не получает новых ключей
вовсе, поведение остальных поставщиков не меняется.

Сброс сожжённого контекста — ровно один на обнаруженный блок, через отложенный флаг
BrowserFetcher.request_context_reset(): fetch_detail() в kit не прокидывает
reset_context, и менять этот промежуточный слой ради одного поставщика не хотелось.

NB для деплоя: правка работает только если tradein-browser пересобран вместе с
бэкендом. Старый сайдкар новые поля молча проигнорирует — не упадёт, но и не починит.

Refs #3190, #3118
This commit is contained in:
bot-backend 2026-08-28 21:43:24 +03:00
parent 49b70a67f3
commit e2863f6be9
7 changed files with 790 additions and 19 deletions

View file

@ -272,7 +272,17 @@ async def run_domclick_detail_backfill(
# module docstring). source="domclick" -- infra identifier, dedicated
# residential proxy (scrape_proxies.provider_affinity='domclick',
# 173_scrape_proxies_add_domclick_affinity.sql).
async with BrowserFetcher(source="domclick", endpoint=settings.browser_http_endpoint) as bf:
# reuse_context=True (#3118): sidecar's browser.new_page() creates a fresh
# isolated context on EVERY /fetch, so the once-per-run cookie injection above
# never sees the live qrator_jsid2 the site rotates via Set-Cookie (~2.5h TTL)
# -- confirmed live: 26 sequential sidecar fetches = 100% blocked, same cards
# in a warm browser context = 5/5 in ~2s each. reuse_context keeps ONE sidecar
# context alive for the whole run instead, so the cookie jar evolves on its
# own. Reset happens once per detected block (see reset_context_next below),
# not on every subsequent fetch.
async with BrowserFetcher(
source="domclick", endpoint=settings.browser_http_endpoint, reuse_context=True
) as bf:
for idx, row in enumerate(snapshot):
# Budget guard
elapsed = time.monotonic() - start
@ -329,6 +339,10 @@ async def run_domclick_detail_backfill(
except DomClickBlockedError as e:
consecutive_blocks += 1
counters.blocked += 1
# #3118: сожжённый переиспользуемый context (протухший qrator_jsid2
# / стухшая сессия) не должен тянуться дальше по прогону — просим
# ОДИН сброс на следующем fetch(), не на каждый последующий запрос.
bf.request_context_reset()
logger.warning(
"domclick_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
run_id,

View file

@ -81,6 +81,10 @@ def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# request_context_reset (#3118) — синхронный метод, не awaited caller'ом; явный
# MagicMock вместо auto-AsyncMock-атрибута, чтобы вызов не оставлял неawaited
# coroutine (RuntimeWarning в тестах с блоками).
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance)
@ -159,7 +163,7 @@ async def test_backfill_processes_snapshot_with_cookies_threaded() -> None:
assert result.failed == 0
assert mock_fetch.call_count == 3
mock_bf_cls.assert_called_once_with(
source="domclick", endpoint=fake_settings.browser_http_endpoint
source="domclick", endpoint=fake_settings.browser_http_endpoint, reuse_context=True
)
for call in mock_fetch.call_args_list:
_, kwargs = call

View file

@ -0,0 +1,295 @@
"""Tests для тёплого переиспользуемого browser-context в DomClick detail-бэкфилле (#3118).
Проблема: sidecar's browser.new_page() создавал НОВЫЙ изолированный context на КАЖДЫЙ
/fetch, поэтому кук-инъекция (см. domclick_session), сделанная ОДИН РАЗ в начале
прогона, не видела Set-Cookie от площадки живой qrator_jsid2 (~2.5ч TTL) протухал
уже после первого запроса. Живой замер: 26 подряд sidecar-фетчей = 100% блоков, те же
карточки в тёплом 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()).
Зеркалит конвенции tests/tasks/test_domclick_detail_backfill.py (module-level
patch-target строки, _mock_db/_mock_session_svc helpers).
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from app.tasks.domclick_detail_backfill import run_domclick_detail_backfill # noqa: E402
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc(cookies: dict[str, str] | None) -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = cookies
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
"""Возвращает (класс-мок, instance-мок) — instance с реальным request_context_reset."""
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# request_context_reset — синхронный метод (не awaited caller'ом), явный MagicMock
# вместо auto-AsyncMock-атрибута, чтобы вызов не оставлял неawaited coroutine.
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance), instance
@pytest.mark.asyncio
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
"""BrowserFetcher поднимается с reuse_context=True (#3118) -- тёплый context sidecar'а."""
snapshot = _make_snapshot(1)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123", "qrator_jsid2": "live"})
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
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_domclick_detail_backfill(
db, run_id=100, params={"batch_size": 10, "budget_sec": 3600}
)
mock_bf_cls.assert_called_once_with(
source="domclick", endpoint=fake_settings.browser_http_endpoint, reuse_context=True
)
@pytest.mark.asyncio
async def test_block_triggers_exactly_one_context_reset_request() -> None:
"""DomClickBlockedError -> bf.request_context_reset() вызывается РОВНО один раз на блок.
3 подряд блока (max_consecutive_blocks=3) -> сброс запрашивается 3 раза (по разу
НА КАЖДЫЙ обнаруженный блок), а не один раз на весь прогон и не на каждый успешный
fetch между блоками.
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=blocked_exc)
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db,
run_id=101,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 3
assert instance.request_context_reset.call_count == 3, (
"ожидался ровно один запрос сброса НА КАЖДЫЙ обнаруженный блок"
)
@pytest.mark.asyncio
async def test_successful_fetch_does_not_request_context_reset() -> None:
"""Успешный прогон без блоков -> request_context_reset() ни разу не вызывается."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db, run_id=102, params={"batch_size": 10, "budget_sec": 3600}
)
assert result.blocked == 0
instance.request_context_reset.assert_not_called()
@pytest.mark.asyncio
async def test_block_then_recovery_requests_reset_only_on_the_block() -> None:
"""Блок -> успех -> успех: сброс запрошен РОВНО один раз (на самом блоке).
Один сброс на блок, не на каждый последующий запрос (см. docstring задачи) --
после восстановления (успешный fetch) счётчик подряд-блоков сбрасывается и
request_context_reset() больше не должен вызываться.
"""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = DomClickBlockedError("browser fetch failed")
mock_fetch = AsyncMock(side_effect=[blocked_exc, MagicMock(), MagicMock()])
fake_settings = MagicMock(browser_http_endpoint="http://browser:9000")
mock_svc = _mock_session_svc({"CAS_ID": "123"})
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION_SVC, mock_svc),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_domclick_detail_backfill(
db,
run_id=103,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 1
assert result.enriched == 2
instance.request_context_reset.assert_called_once()
# ── BrowserFetcher: сама логика отложенного флага (#3118) ──────────────────────
# Тесты выше мокают BrowserFetcher целиком и проверяют ОРКЕСТРАТОР («на блок зовём
# request_context_reset ровно один раз»). Ниже — реальный BrowserFetcher с моком
# _post_fetch: проверяем потребление флага, ради которого метод и заведён.
def _fetcher(*, reuse_context: bool):
from scraper_kit.browser_fetcher import BrowserFetcher
bf = BrowserFetcher(
source="domclick", endpoint="http://browser:3000", reuse_context=reuse_context
)
bf._client = MagicMock() # обходим assert «используй как async context manager»
return bf
@pytest.mark.asyncio
async def test_3118_pending_reset_consumed_exactly_once():
"""request_context_reset() влияет РОВНО на один следующий fetch, дальше — обычные."""
bf = _fetcher(reuse_context=True)
seen: list[bool] = []
async def _fake_post(url, origin=None, cookies=None, reset_context=False):
seen.append(reset_context)
return "<html></html>"
with patch.object(bf, "_post_fetch", side_effect=_fake_post):
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__1")
bf.request_context_reset()
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__2")
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__3")
assert seen == [False, True, False], seen
assert bf._context_reset_pending is False
@pytest.mark.asyncio
async def test_3118_pending_reset_survives_transport_retry():
"""Transport-ошибка → retry идёт с тем же reset (сброс на сайдкаре идемпотентен)."""
import httpx
bf = _fetcher(reuse_context=True)
seen: list[bool] = []
async def _fake_post(url, origin=None, cookies=None, reset_context=False):
seen.append(reset_context)
if len(seen) == 1:
raise httpx.ConnectError("boom")
return "<html></html>"
with (
patch.object(bf, "_post_fetch", side_effect=_fake_post),
patch("scraper_kit.browser_fetcher.asyncio.sleep", new=AsyncMock()),
):
bf.request_context_reset()
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__4")
assert seen == [True, True], seen
assert bf._context_reset_pending is False
@pytest.mark.asyncio
async def test_3118_payload_unchanged_when_reuse_context_off():
"""reuse_context=False (дефолт) → в payload /fetch новых ключей нет вовсе."""
bf = _fetcher(reuse_context=False)
captured: dict = {}
class _Resp:
@staticmethod
def json():
return {"html": "<html></html>"}
async def _fake_client_post(url, json=None):
captured.update(json or {})
return _Resp()
bf._client.post = _fake_client_post
with (
patch("scraper_kit.browser_fetcher._raise_for_sidecar_status", new=MagicMock()),
patch.object(bf, "_report_fetch_result", new=MagicMock()),
):
bf.request_context_reset() # no-op при выключенном флаге
await bf.fetch("https://ekaterinburg.domclick.ru/card/sale__flat__5")
assert "reuse_context" not in captured
assert "reset_context" not in captured

View file

@ -366,6 +366,13 @@ def _parse_proxy(proxy_url: str | None) -> dict[str, str] | None:
# одновременно. Разные поставщики держат РАЗНЫЕ локи → работают параллельно.
_browsers: dict[str, object] = {} # provider → Browser
_browser_cms: dict[str, object] = {} # provider → AsyncCamoufox CM (для __aexit__)
# provider → переиспользуемый BrowserContext (#3118, opt-in reuse_context). Создаётся
# ЛЕНИВО в _get_or_create_context на первом /fetch с reuse_context=True; куки вливаются
# ТОЛЬКО в момент создания — дальше cookie-jar эволюционирует сам через Set-Cookie
# площадки (browser.new_page() без reuse_context создаёт НОВЫЙ изолированный context на
# каждый вызов и убивает живой qrator_jsid2 DomClick, см. #3118). При выключенном флаге
# (дефолт) словарь остаётся пустым — поведение прочих провайдеров не меняется.
_contexts: dict[str, object] = {}
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
@ -464,8 +471,29 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
)
async def _close_reusable_context(provider: str) -> None:
"""Закрывает и выбрасывает сохранённый переиспользуемый context провайдера (#3118).
No-op если контекста нет (флаг reuse_context никогда не включался или уже сброшен).
Best-effort ошибка закрытия не должна ронять caller'а (reset_context/relaunch/
shutdown идут дальше вне зависимости от исхода close()).
"""
ctx = _contexts.pop(provider, None)
if ctx is None:
return
try:
await ctx.close() # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: ошибка при закрытии переиспользуемого context: %s",
provider,
type(exc).__name__,
)
async def _close_browser(provider: str) -> None:
"""Закрывает per-provider браузер и чистит состояние для поставщика."""
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
await _close_reusable_context(provider)
cm = _browser_cms.get(provider)
if cm is not None:
try:
@ -764,6 +792,18 @@ async def fetch_handler(request: web.Request) -> web.Response:
# yandex его никогда не передают).
cookies: dict | None = body.get("cookies")
# reuse_context/reset_context (#3118) — тёплый переиспользуемый browser-context для
# DomClick detail-бэкфилла: browser.new_page() каждый раз создаёт НОВЫЙ изолированный
# context, и куки (см. cookies выше), залитые ОДИН РАЗ, не видят Set-Cookie от
# площадки — живой qrator_jsid2 (~2.5ч TTL) протухает уже после первого запроса.
# reuse_context=True держит per-provider context живым между вызовами (куки вливаются
# ТОЛЬКО при его создании, дальше jar эволюционирует сам); reset_context=True
# выбрасывает сожжённый context ДО работы (следующее создание будет чистым). Оба
# дефолт False — поведение всех прочих provider'ов (avito/cian/yandex/generic) не
# меняется ни на байт.
reuse_context: bool = bool(body.get("reuse_context", False))
reset_context: bool = bool(body.get("reset_context", False))
provider = _resolve_provider(body, url)
proxy_override = _resolve_proxy_override(body, provider)
@ -796,7 +836,14 @@ async def fetch_handler(request: web.Request) -> web.Response:
)
try:
html = await _do_fetch(provider, url, origin=origin, cookies=cookies)
html = await _do_fetch(
provider,
url,
origin=origin,
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
)
except Exception as exc:
logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s",
@ -952,17 +999,30 @@ async def _do_fetch(
*,
origin: str | None = None,
cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
) -> str:
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
Caller держит _locks[provider] (нет параллельных страниц на этом инстансе),
поэтому relaunch безопасен. Crash-relaunch сохраняет текущий прокси инстанса
(_relaunch_browser reuse _launched_proxy) динамический прокси пула не теряется.
Crash-relaunch (через _close_browser) заодно выбрасывает переиспользуемый context
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
а не обратится к context'у уже мёртвого браузера.
origin/cookies см. _fetch_once. None (дефолт) не меняет поведение.
origin/cookies/reuse_context/reset_context см. _fetch_once. Дефолты не меняют
поведение.
"""
try:
return await _fetch_once(provider, url, origin=origin, cookies=cookies)
return await _fetch_once(
provider,
url,
origin=origin,
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
)
except Exception as exc:
if _is_browser_crash(exc):
logger.warning(
@ -974,7 +1034,14 @@ async def _do_fetch(
await _relaunch_browser(provider)
if _browsers.get(provider) is None:
raise
return await _fetch_once(provider, url, origin=origin, cookies=cookies)
return await _fetch_once(
provider,
url,
origin=origin,
cookies=cookies,
reuse_context=reuse_context,
reset_context=reset_context,
)
raise
@ -1110,14 +1177,46 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
return settled
async def _get_or_create_context(
provider: str,
browser: object,
cookies: dict | None,
url: str,
) -> object:
"""Возвращает сохранённый переиспользуемый context провайдера либо создаёт новый (#3118).
Куки вливаются ТОЛЬКО в момент создания в уже существующий (сохранённый) context
add_cookies больше НИКОГДА не вызывается: повторная заливка замороженного снимка
из БД убивала бы живой qrator_jsid2, который площадка успела выдать через
Set-Cookie, ровно это и было причиной бана DomClick detail-бэкфилла.
"""
existing = _contexts.get(provider)
if existing is not None:
return existing
context = await browser.new_context() # type: ignore[attr-defined]
if cookies:
cookie_domain = f".{urlparse(url).hostname or ''}"
await context.add_cookies( # type: ignore[attr-defined]
[
{"name": name, "value": value, "domain": cookie_domain, "path": "/"}
for name, value in cookies.items()
]
)
_contexts[provider] = context
logger.info("tradein-browser[%s]: создан переиспользуемый browser context (#3118)", provider)
return context
async def _fetch_once(
provider: str,
url: str,
*,
origin: str | None = None,
cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
) -> str:
"""Открывает СОБСТВЕННУЮ страницу, переходит по URL, ждёт JS, возвращает HTML.
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся recycle через _relaunch_browser безопасен прямо здесь.
@ -1128,21 +1227,42 @@ async def _fetch_once(
card-fetch, эмпирически подтверждено вживую 2026-07-04). None (дефолт) поведение
не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin).
cookies (опционально) dict cookie_namevalue для инъекции в контекст страницы
ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии,
эмпирически подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ
захардкожен, а выводится из hostname целевого url (с ведущей точкой покрывает
поддомены, зеркалит реальную DomClick cookie-scope ".domclick.ru" для
cookies (опционально) dict cookie_namevalue для инъекции ДО любой навигации
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
выводится из hostname целевого url (с ведущей точкой покрывает поддомены,
зеркалит реальную DomClick cookie-scope ".domclick.ru" для
ekaterinburg.domclick.ru/spb.domclick.ru/etc, но работает для любого хоста, не
только DomClick) механизм пригоден для будущих caller'ов (Cian/Avito/Yandex).
None (дефолт) без инъекции, поведение не меняется (avito/cian/yandex сейчас
cookies не передают).
При reuse_context=False (дефолт) вливаются в КАЖДУЮ новую страницу, как раньше.
При reuse_context=True ТОЛЬКО в момент создания переиспользуемого context'а
(см. _get_or_create_context); в уже существующий context повторно не вливаются
никогда. None (дефолт) без инъекции, поведение не меняется.
reuse_context (#3118, опционально, дефолт False) — держит per-provider
BrowserContext живым между вызовами вместо browser.new_page() (который создаёт
НОВЫЙ изолированный context на КАЖДЫЙ запрос и убивает cookie-jar в т.ч. живой
qrator_jsid2 DomClick, ~2.5ч TTL, ротируемый площадкой через Set-Cookie). Страница
создаётся ИЗ этого context'а и закрывается после отдачи HTML, сам context остаётся
жить. False (дефолт) browser.new_page() как раньше avito/cian/yandex/generic
его никогда не включают, поведение не меняется ни на байт.
reset_context (#3118, опционально, дефолт False) — закрывает и выбрасывает
сохранённый context провайдера ДО работы (следующий reuse_context=True создаст его
заново, с чистым cookie-jar). Вызывающая сторона использует его ровно один раз на
обнаруженный блок, не на каждый последующий запрос.
"""
browser = _browsers.get(provider)
assert browser is not None, "browser not launched"
page = await browser.new_page() # type: ignore[attr-defined]
try:
if reset_context:
await _close_reusable_context(provider)
if reuse_context:
context = await _get_or_create_context(provider, browser, cookies, url)
page = await context.new_page() # type: ignore[attr-defined]
else:
page = await browser.new_page() # type: ignore[attr-defined]
if cookies:
cookie_domain = f".{urlparse(url).hostname or ''}"
await page.context.add_cookies( # type: ignore[attr-defined]
@ -1151,6 +1271,7 @@ async def _fetch_once(
for name, value in cookies.items()
]
)
try:
await _apply_resource_block(page)
await _pace_provider(provider)
if origin:

View file

@ -174,6 +174,8 @@ class _OverlapProbe:
*,
origin: str | None = None,
cookies: dict | None = None,
reuse_context: bool = False,
reset_context: bool = False,
) -> str:
self.active[provider] = self.active.get(provider, 0) + 1
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])

View file

@ -0,0 +1,277 @@
"""test_server_reuse_context.py — тёплый переиспользуемый browser context (#3118).
Проблема: browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch,
поэтому залитый ОДИН РАЗ снимок cookies (см. cookies в /fetch) не видит Set-Cookie
от площадки живой qrator_jsid2 DomClick (~2.5ч TTL) протухает после первого же
запроса. reuse_context=True держит per-provider context живым между вызовами; куки
вливаются ТОЛЬКО в момент его создания. reset_context=True выбрасывает сожжённый
context ДО работы.
Проверяет:
1. reuse_context=True context создаётся ОДИН раз на несколько _fetch_once,
куки вливаются только при создании (не повторно);
2. reset_context=True старый context закрывается и выбрасывается, следующий
_fetch_once создаёт новый (с новой инъекцией кук);
3. reuse_context=False (дефолт) поведение НЕ меняется: browser.new_page()
на каждый вызов, context вообще не создаётся/не трогается;
4. _close_browser закрывает и выбрасывает сохранённый context провайдера.
camoufox НЕ запускается browser/context/page подделаны.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_reuse_context.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server, "_browsers", {})
monkeypatch.setattr(server, "_browser_cms", {})
monkeypatch.setattr(server, "_contexts", {})
monkeypatch.setattr(server, "_page_counters", {})
monkeypatch.setattr(server, "_locks", {})
monkeypatch.setattr(server, "_last_goto_at", {})
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
class _FakePage:
"""Поддельная page: фиксирует goto/close, минимальный контракт _fetch_once."""
def __init__(self) -> None:
self.goto_urls: list[str] = []
self.closed = 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
async def wait_for_timeout(self, ms: int) -> None:
return None
async def content(self) -> str:
return "<html>ok</html>"
async def close(self) -> None:
self.closed += 1
class _FakeContext:
"""Поддельный BrowserContext: фиксирует add_cookies/new_page/close."""
def __init__(self) -> None:
self.add_cookies_calls: list[list[dict]] = []
self.pages: list[_FakePage] = []
self.closed = 0
async def add_cookies(self, cookies: list[dict]) -> None:
self.add_cookies_calls.append(cookies)
async def new_page(self) -> _FakePage:
page = _FakePage()
self.pages.append(page)
return page
async def close(self) -> None:
self.closed += 1
class _FakeReuseBrowser:
"""Поддельный Browser: считает new_context()/new_page() вызовы, копит контексты."""
def __init__(self) -> None:
self.contexts_created: list[_FakeContext] = []
self.new_page_calls = 0
async def new_context(self) -> _FakeContext:
ctx = _FakeContext()
self.contexts_created.append(ctx)
return ctx
async def new_page(self) -> _FakePage:
self.new_page_calls += 1
return _FakePage()
# ── reuse_context=True: один context на несколько fetch ───────────────────────────
def test_reuse_context_creates_context_once_across_fetches() -> None:
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
async def _run() -> None:
await server._fetch_once("avito", "https://www.avito.ru/1", reuse_context=True)
await server._fetch_once("avito", "https://www.avito.ru/2", reuse_context=True)
await server._fetch_once("avito", "https://www.avito.ru/3", reuse_context=True)
asyncio.run(_run())
assert len(browser.contexts_created) == 1, "context должен создаваться РОВНО один раз"
ctx = browser.contexts_created[0]
assert len(ctx.pages) == 3, "каждый fetch — своя страница ИЗ переиспользуемого context"
assert browser.new_page_calls == 0, "browser.new_page() не должен зваться при reuse_context"
# Все страницы закрыты, context — нет (остаётся жить).
assert all(p.closed == 1 for p in ctx.pages)
assert ctx.closed == 0
assert server._contexts["avito"] is ctx
def test_reuse_context_injects_cookies_only_on_creation() -> None:
browser = _FakeReuseBrowser()
server._browsers["domclick"] = browser
cookies = {"qrator_jsid2": "live-token"}
async def _run() -> None:
await server._fetch_once(
"domclick", "https://domclick.ru/card/1", cookies=cookies, reuse_context=True
)
await server._fetch_once(
"domclick", "https://domclick.ru/card/2", cookies=cookies, reuse_context=True
)
asyncio.run(_run())
ctx = browser.contexts_created[0]
assert len(ctx.add_cookies_calls) == 1, "повторный fetch НЕ должен вливать куки заново"
injected = ctx.add_cookies_calls[0]
assert {
"name": "qrator_jsid2",
"value": "live-token",
"domain": ".domclick.ru",
"path": "/",
} in injected
# ── reset_context=True: старый context выбрасывается, следующий — заново ──────────
def test_reset_context_discards_old_context_and_creates_new() -> None:
browser = _FakeReuseBrowser()
server._browsers["domclick"] = browser
async def _run() -> None:
await server._fetch_once(
"domclick", "https://domclick.ru/1", cookies={"a": "1"}, reuse_context=True
)
await server._fetch_once(
"domclick",
"https://domclick.ru/2",
cookies={"a": "2"},
reuse_context=True,
reset_context=True,
)
asyncio.run(_run())
assert len(browser.contexts_created) == 2, "reset_context должен вызвать пересоздание"
old_ctx, new_ctx = browser.contexts_created
assert old_ctx.closed == 1, "старый context должен быть закрыт"
assert new_ctx.add_cookies_calls == [
[{"name": "a", "value": "2", "domain": ".domclick.ru", "path": "/"}]
]
assert server._contexts["domclick"] is new_ctx
def test_reset_context_without_reuse_is_noop_for_new_page_path() -> None:
"""reset_context=True без reuse_context (не должно ломать текущий new_page-путь)."""
browser = _FakeReuseBrowser()
server._browsers["avito"] = browser
html = asyncio.run(
server._fetch_once("avito", "https://www.avito.ru/1", reset_context=True)
)
assert html == "<html>ok</html>"
assert browser.new_page_calls == 1
assert browser.contexts_created == []
# ── reuse_context=False (дефолт): golden-parity, поведение не меняется ────────────
def test_reuse_context_false_uses_new_page_every_time() -> None:
browser = _FakeReuseBrowser()
server._browsers["cian"] = browser
async def _run() -> None:
await server._fetch_once("cian", "https://www.cian.ru/1")
await server._fetch_once("cian", "https://www.cian.ru/2")
asyncio.run(_run())
assert browser.new_page_calls == 2
assert browser.contexts_created == [], "reuse_context=False не должен создавать context"
assert "cian" not in server._contexts
def test_reuse_context_false_still_injects_cookies_into_page_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Golden-parity: cookies без reuse_context вливаются в page.context, как раньше."""
class _FakePageContext:
def __init__(self) -> None:
self.add_cookies_calls: list[list[dict]] = []
async def add_cookies(self, cookies: list[dict]) -> None:
self.add_cookies_calls.append(cookies)
class _PageWithContext(_FakePage):
def __init__(self) -> None:
super().__init__()
self.context = _FakePageContext()
class _Browser:
def __init__(self) -> None:
self.page = _PageWithContext()
async def new_page(self) -> _PageWithContext:
return self.page
browser = _Browser()
server._browsers["yandex"] = browser
asyncio.run(
server._fetch_once("yandex", "https://realty.yandex.ru/1", cookies={"sid": "x"})
)
assert browser.page.context.add_cookies_calls == [
[{"name": "sid", "value": "x", "domain": ".realty.yandex.ru", "path": "/"}]
]
# ── _close_browser закрывает переиспользуемый context (#3118) ─────────────────────
def test_close_browser_closes_and_pops_reusable_context() -> None:
ctx = _FakeContext()
server._contexts["avito"] = ctx
asyncio.run(server._close_browser("avito"))
assert ctx.closed == 1
assert "avito" not in server._contexts
def test_close_browser_noop_when_no_context() -> None:
"""Нет сохранённого context — _close_browser не падает (no-op ветка)."""
asyncio.run(server._close_browser("generic"))
assert "generic" not in server._contexts

View file

@ -272,6 +272,7 @@ class BrowserFetcher:
proxy_provider: ProxyProvider | None = None,
use_pool: bool = False,
environment: str = "dev",
reuse_context: bool = False,
) -> None:
# source — логический источник ("avito"/"cian"/"yandex"/"domclick"). Сервер
# роутит /fetch по нему на отдельный браузер+прокси, когда включён
@ -306,6 +307,22 @@ class BrowserFetcher:
# падаем на env-прокси (все мертвы, #2613) — NoProxyAvailableError вместо тела
# без "proxy" (см. _acquire_lease). Дефолт "dev" — легитимный fallback на env
# для dev/test, поведение не меняется.
# reuse_context (#3118) — сессионный флаг: держать тёплый переиспользуемый
# browser-context сайдкара (tradein-browser server.py::_get_or_create_context)
# вместо browser.new_page() на каждый /fetch. Нужен, потому что new_page()
# создаёт НОВЫЙ изолированный context на каждый вызов и убивает cookie-jar — в
# т.ч. живой qrator_jsid2 DomClick, который куки-инъекция (см. fetch()) не
# успевает подхватить повторно. False (дефолт) → payload /fetch остаётся БЕЗ
# ключа reuse_context (см. _post_fetch) — сайдкар старой версии, не знающий
# этого поля, ничего не заметит; поведение всех прочих providers не меняется.
self._reuse_context = reuse_context
# Отложенный запрос сброса context'а сайдкара (#3118) — выставляется
# request_context_reset() (caller не имеет прямого доступа к call-сайту
# fetch(), напр. domclick_detail_backfill вызывает его через fetch_detail(),
# который reset_context НЕ прокидывает) и потребляется РОВНО одним следующим
# fetch() (см. fetch()), затем сбрасывается — один флаг на один блок, не на
# каждый последующий запрос.
self._context_reset_pending: bool = False
self._source = source
self._fetch_timeout_s = fetch_timeout_s
self._client: httpx.AsyncClient | None = None
@ -354,6 +371,7 @@ class BrowserFetcher:
*,
origin: str | None = None,
cookies: dict[str, str] | None = None,
reset_context: bool = False,
) -> str:
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
@ -368,6 +386,13 @@ class BrowserFetcher:
domclick_session). None (дефолт) поведение не меняется, инъекции нет
таков путь всех providers кроме domclick detail-debug.
reset_context (#3118) — просит сайдкар выбросить сохранённый переиспользуемый
context ЭТОГО источника ДО фетча (см. reuse_context в __init__). Имеет смысл
только вместе с reuse_context=True; при reuse_context=False payload остаётся
неизменным (см. _post_fetch) параметр молча игнорируется сайдкаром.
Фактически применяемое значение ``reset_context OR`` отложенный флаг из
request_context_reset() (потребляется здесь ровно один раз).
При HTTPError или ConnectError делает одну повторную попытку после
короткой паузы. Остальные исключения всплывают к вызывающему коду.
@ -377,8 +402,18 @@ class BrowserFetcher:
assert self._client is not None, "BrowserFetcher: используй как async context manager"
assert self._endpoint is not None, "BrowserFetcher: endpoint не задан"
# Отложенный флаг потребляется РОВНО здесь и сразу гасится — следующий fetch()
# уже не сбрасывает контекст (один сброс на один блок). На retry ниже тот же
# effective_reset уходит вторым POST'ом ОСОЗНАННО: сброс на стороне сайдкара
# идемпотентен (pop с дефолтом), а исход первого POST'а неизвестен — он мог
# упасть транспортом как до, так и после пересоздания контекста. Цена лишнего
# срабатывания — один new_context()+add_cookies(), цена пропущенного — прогон
# доживает на сожжённом контексте, поэтому выбран заведомо безопасный вариант.
effective_reset = reset_context or self._context_reset_pending
self._context_reset_pending = False
try:
return await self._post_fetch(url, origin, cookies)
return await self._post_fetch(url, origin, cookies, effective_reset)
except (httpx.HTTPError, httpx.TransportError) as exc:
logger.warning(
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
@ -387,7 +422,7 @@ class BrowserFetcher:
url,
)
await asyncio.sleep(_RETRY_SLEEP_S)
return await self._post_fetch(url, origin, cookies)
return await self._post_fetch(url, origin, cookies, effective_reset)
async def fetch_json(
self,
@ -530,6 +565,20 @@ class BrowserFetcher:
"BrowserFetcher: proxy_pool release failed for %s", self._source, exc_info=True
)
def request_context_reset(self) -> None:
"""Просит сбросить переиспользуемый sidecar-context на СЛЕДУЮЩЕМ fetch() (#3118).
Нужен вызывающему коду, который не дёргает fetch() напрямую (например,
domclick_detail_backfill идёт через scraper_kit.providers.domclick.detail.
fetch_detail(), который reset_context не прокидывает) сюда можно
сигнализировать «контекст сожжён блоком» без изменения того промежуточного
слоя. Потребляется ровно один раз: следующий fetch() применит сброс и
обнулит флаг сам, дальнейшие запросы идут как обычно (никакого «сброс на
каждый последующий»). No-op, если reuse_context=False тогда fetch() всё
равно не кладёт reuse_context/reset_context в payload.
"""
self._context_reset_pending = True
def report_ban(self, reason: str) -> None:
"""Пометить ТЕКУЩИЙ lease забаненным площадкой (#2600 п.1, п.2).
@ -641,6 +690,7 @@ class BrowserFetcher:
url: str,
origin: str | None = None,
cookies: dict[str, str] | None = None,
reset_context: bool = False,
) -> str:
"""Один HTTP POST к /fetch эндпоинту сервиса.
@ -648,6 +698,11 @@ class BrowserFetcher:
_post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
корректно обрабатывает оба случая.
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
self._reuse_context включён при выключенном флаге (дефолт) payload остаётся
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
схеме) не должен получать неожиданные ключи.
proxy из ТЕКУЩЕГО session-lease (_current_proxy), НЕ acquire на каждый вызов
(#2164 P4 sticky-session fix, живая регрессия 2026-08). Исход репортится в lease
через _report_fetch_result (touch-heartbeat + mark_health + осознанная ротация
@ -663,6 +718,9 @@ class BrowserFetcher:
"origin": origin,
"cookies": cookies,
}
if self._reuse_context:
payload["reuse_context"] = True
payload["reset_context"] = reset_context
if proxy_url:
payload["proxy"] = proxy_url
if proxy_kind: