diff --git a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py index 9e09066f..f4786ffa 100644 --- a/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/domclick_detail_backfill.py @@ -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, diff --git a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py index 239b4ab3..50862dae 100644 --- a/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_domclick_detail_backfill.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py new file mode 100644 index 00000000..dc4559e4 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py @@ -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 "" + + 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 "" + + 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": ""} + + 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 diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index b921d04f..441df916 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -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_name→value для инъекции в контекст страницы - ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, - эмпирически подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ - захардкожен, а выводится из hostname целевого url (с ведущей точкой — покрывает - поддомены, зеркалит реальную DomClick cookie-scope ".domclick.ru" для + cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации + (обход 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: diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 98c94d22..3ecaae35 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -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]) diff --git a/tradein-mvp/browser/test_server_reuse_context.py b/tradein-mvp/browser/test_server_reuse_context.py new file mode 100644 index 00000000..cd828b12 --- /dev/null +++ b/tradein-mvp/browser/test_server_reuse_context.py @@ -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 "ok" + + 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 == "ok" + 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 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index e5421aec..3dea2b6e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -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: