From e2863f6be98c998d77a442af70b4c268b968f93a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 28 Aug 2026 21:43:24 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(tradein/domclick):=20=D0=BA=D0=B0=D0=B6?= =?UTF-8?q?=D0=B4=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B0=D0=BB=20=D1=87=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=8B=D0=B9=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82,=20=D0=BA=D1=83=D0=BA=D0=B8=20=D1=80=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=B8=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BF=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D1=83=D1=85=D1=88=D0=B8=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сайдкар на каждый /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 --- .../app/tasks/domclick_detail_backfill.py | 16 +- .../tasks/test_domclick_detail_backfill.py | 6 +- .../tests/test_3118_domclick_warm_context.py | 295 ++++++++++++++++++ tradein-mvp/browser/server.py | 151 ++++++++- tradein-mvp/browser/test_server.py | 2 + .../browser/test_server_reuse_context.py | 277 ++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 62 +++- 7 files changed, 790 insertions(+), 19 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3118_domclick_warm_context.py create mode 100644 tradein-mvp/browser/test_server_reuse_context.py 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: -- 2.45.3 From bb59caa90c25f435d2f0b96014b14ec186e719f9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 28 Aug 2026 21:53:02 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(tradein/scrapers):=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B5=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D0=AF=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81.=D0=9D=D0=B5=D0=B4=D0=B2=D0=B8=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер через прод-сайдкар, две карточки, один тракт с куками и без: поле author.phoneNumbers присутствует только в авторизованном режиме (0 без кук, 2 с куками, одинаково на обеих), плюс три номера автора сверху. Это контакты продавца с тегом канала и redirectId. encryptedPhones при этом одинаков в обоих режимах (54) — зашифрованные токены отдаются всегда, различие не в них. listings.phones (jsonb) существует в схеме и пуст у всех 51 251 активного объявления по всем четырём источникам. Яндекс станет первым источником с контактами. Форма таблицы и сервиса повторяет работающие в проде cian_session_cookies и domclick_session_cookies: pgp_sym_encrypt, expires_at_estimate, last_used_at/last_invalid_at, предупреждение о протухании. Два отличия от образцов, оба намеренные: Хранится ПОЛНЫЙ CDP-объект куки (name/value/domain/path/httpOnly/ secure/expires), а не плоский dict name→value. У Яндекса куки живут на трёх доменах (.yandex.ru, .passport.yandex.ru, .realty.yandex.ru) и дублируют имена (pi ×4, yashr ×4) — плоский словарь их схлопывает. Сайдкар сегодня принимает только плоский формат; когда его контракт починят, хранилище уже готово. Набор сохраняется целиком, минус очевидная аналитика (_ym_*, yabs-*, _yasc*). Список критичных кук используется ТОЛЬКО для проверки, что дамп похож на авторизованную сессию, и не является фильтром сохранения: замер сделан на полном наборе 44 кук, и какая из них существенна — неизвестно. Сужение до allowlist сломало бы измеренное; это защищено комментариями и тестом. Роуты закрыты на двух уровнях: Caddy basic_auth снаружи и rbac_guard внутри (role=admin для /api/v1/admin/*). Refs #3192 --- tradein-mvp/backend/app/api/v1/admin.py | 126 +++++++++ .../backend/app/services/yandex_session.py | 254 ++++++++++++++++++ .../data/sql/274_yandex_session_cookies.sql | 48 ++++ .../test_admin_yandex_session_endpoints.py | 168 ++++++++++++ .../backend/tests/test_yandex_session.py | 215 +++++++++++++++ 5 files changed, 811 insertions(+) create mode 100644 tradein-mvp/backend/app/services/yandex_session.py create mode 100644 tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql create mode 100644 tradein-mvp/backend/tests/test_admin_yandex_session_endpoints.py create mode 100644 tradein-mvp/backend/tests/test_yandex_session.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index d4cb58cc..2d742787 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -9,6 +9,7 @@ import asyncio import json import logging import time +from datetime import datetime from typing import Annotated, Any, Literal from urllib.parse import urlparse, urlunparse from uuid import uuid4 @@ -72,6 +73,7 @@ from app.services import cian_session as cian_session_svc from app.services import domclick_session as domclick_session_svc from app.services import proxy_rotation as proxy_rotation_svc from app.services import scrape_runs as runs_mod +from app.services import yandex_session as yandex_session_svc from app.services.estimator import LISTINGS_FRESH_DAYS from app.services.geocoder import geocode, known_city_hint from app.services.proxy_egress import ProxyPoolExhaustedError, resolve_proxy_url @@ -722,6 +724,130 @@ async def debug_domclick_detail_fetch( ) +# ── Yandex session cookie management (mainPhone/contact reveal, #3192) ─────── +# +# Эмпирически подтверждено на проде (сайдкар, две карточки, режимы вперемежку, +# 2026-08-28): авторизованная сессия Яндекс.Недвижимости отдаёт ключ mainPhone +# и на 11 больше уникальных телефонов продавца (90→101 / 89→100), чем анонимный +# запрос той же карточки. Cookies хранятся зашифрованно (pgp_sym_encrypt) в +# yandex_session_cookies. +# +# В отличие от Cian/DomClick — payload это ПОЛНЫЙ CDP-дамп (Network.getAllCookies: +# список объектов name/value/domain/path/httpOnly/secure/expires), не плоский +# dict[name, value], и НЕТ allowlist-фильтра на сохранение (см. +# app.services.yandex_session.filter_analytics_cookies docstring) — сохраняем +# весь дамп, кроме заведомой аналитики. +# +# MVP: без verify_session (зеркалит domclick_session) — инъекция кук в browser +# context и реальный fetch-verify — следующая часть #3192 (providers/yandex/**, +# browser/server.py), не эта. + + +class YandexCookieItem(BaseModel): + """Один cookie-объект из CDP Network.getAllCookies (или эквивалентного дампа).""" + + name: str + value: str + domain: str | None = None + path: str | None = None + # CDP отдаёт camelCase "httpOnly" — alias, чтобы не нарушать N815 (ruff) + # snake_case-конвенцию полей. + http_only: bool | None = Field(default=None, alias="httpOnly") + secure: bool | None = None + expires: float | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class YandexUploadCookiesRequest(BaseModel): + account_label: str + cookies: list[YandexCookieItem] + + +@router.post("/scrape/yandex/upload-cookies", status_code=200) +async def upload_yandex_cookies( + body: YandexUploadCookiesRequest, + db: Annotated[Session, Depends(get_db)], +) -> dict: + """Upload Yandex.Nedvizhimost session cookies (авторизованный contact-reveal, #3192). + + Body: {"account_label": "<метка аккаунта>", "cookies": [{"name": ..., "value": ..., + "domain": ..., "path": ..., "httpOnly": ..., "secure": ..., "expires": ...}, ...]} + — прямой дамп CDP Network.getAllCookies (DevTools Protocol) или его JSON-эквивалент. + account_label — произвольная текстовая метка аккаунта (у Яндекса нет числового + id, доступного из cookie-дампа, в отличие от Cian userId / DomClick CAS_ID). + + Шаги: + 1. Отбрасывает заведомую аналитику (_ym_*, yabs-*, _yasc*) — + yandex_session_svc.filter_analytics_cookies. Остальное сохраняется КАК + ПРИШЛО — здесь нет allowlist-фильтра, как у Cian/DomClick, см. модульный + docstring app.services.yandex_session. + 2. Проверяет наличие ключевой auth-cookie (Session_id) — её отсутствие значит + дамп анонимный, сохранять его бессмысленно. + 3. Сохраняет зашифрованно (pgp_sym_encrypt) в yandex_session_cookies. + + Returns: {"ok": true, "accountLabel": , "cookieCount": } + """ + if not settings.cookie_encryption_key: + raise HTTPException(status_code=503, detail="COOKIE_ENCRYPTION_KEY not configured") + + account_label = body.account_label.strip() + if not account_label: + raise HTTPException(status_code=400, detail="account_label must not be empty") + + # by_alias=True — сохраняем оригинальные CDP-ключи (httpOnly, не http_only): + # будущая browser-инъекция (следующая часть #3192) ожидает формат + # Network.getAllCookies как есть. + raw_cookies = [c.model_dump(exclude_none=True, by_alias=True) for c in body.cookies] + cleaned = yandex_session_svc.filter_analytics_cookies(raw_cookies) + + if not cleaned: + raise HTTPException(status_code=400, detail="No cookies left after analytics filter") + + if not yandex_session_svc.has_auth_marker(cleaned): + raise HTTPException( + status_code=400, + detail=( + f"В дампе нет ключевой auth-cookie ({yandex_session_svc.AUTH_MARKER_COOKIE!r})" + " — похоже на анонимную сессию, залейте дамп из авторизованного окна." + ), + ) + + yandex_session_svc.save_session(db, account_label=account_label, cookies=cleaned) + return {"ok": True, "accountLabel": account_label, "cookieCount": len(cleaned)} + + +@router.get("/scrape/yandex/session-status", status_code=200) +async def get_yandex_session_status( + db: Annotated[Session, Depends(get_db)], +) -> dict: + """Статус сохранённой Yandex-сессии — есть ли валидные куки и когда протухают. + + Без verify_session (MVP, см. модульный docstring yandex_session) — статус + основан только на expires_at_estimate/last_invalid_at из БД, не на живом + fetch к Яндексу. + + Returns: {"hasSession": bool, "expiresAt": , "expiresSoon": bool} + """ + if not settings.cookie_encryption_key: + return {"hasSession": False, "expiresAt": None, "expiresSoon": False} + + cookies = yandex_session_svc.load_session(db) + has_session = cookies is not None + + expires_at = yandex_session_svc.session_expires_at(db, valid_only=has_session) + expires_soon = False + if expires_at is not None: + days_left = (expires_at - datetime.now(expires_at.tzinfo)).days + expires_soon = days_left <= yandex_session_svc.COOKIE_EXPIRY_WARN_DAYS + + return { + "hasSession": has_session, + "expiresAt": expires_at.isoformat() if expires_at else None, + "expiresSoon": expires_soon, + } + + # ── Geocode backfill: batch address-dedup geocoder (all sources) ───────────── diff --git a/tradein-mvp/backend/app/services/yandex_session.py b/tradein-mvp/backend/app/services/yandex_session.py new file mode 100644 index 00000000..b48fa679 --- /dev/null +++ b/tradein-mvp/backend/app/services/yandex_session.py @@ -0,0 +1,254 @@ +"""Yandex.Nedvizhimost session cookie management — load/save/invalidate encrypted cookies. + +Замер на проде (#3192, сайдкар, две карточки, режимы вперемежку, 2026-08-28): +авторизованная сессия Яндекс.Недвижимости отдаёт ключ `mainPhone` и на 11 больше +уникальных телефонов продавца (90→101 / 89→100), чем анонимный запрос той же +карточки — контакты продавца физически недоступны без валидной сессии. + +Cookies хранятся зашифрованно (pgp_sym_encrypt) в yandex_session_cookies — +зеркалит app.services.cian_session / app.services.domclick_session, но: + + - ключ записи — account_label (text), НЕ числовой id: у Яндекса нет внутреннего + числового id аккаунта, доступного из cookie-дампа; + - хранится ПОЛНЫЙ CDP cookie-объект (name/value/domain/path/httpOnly/secure/ + expires), а не плоский dict[name, value] как у Cian/DomClick — инъекция кук в + browser context (следующая часть #3192) требует domain/path, не только value; + - НЕТ allowlist-фильтра на сохранение (см. filter_analytics_cookies docstring) — + Cian/DomClick фильтруют payload по списку известных cookie-имён перед + сохранением, здесь так делать нельзя: замер выше сделан на ПОЛНОМ наборе из + 44 cookies, и какая из них существенна для mainPhone-раскрытия — неизвестно. + +MVP: без verify_session (аналогично domclick_session) — верификация Yandex-сессии +требует реального browser-фетча, не простого curl_cffi-запроса; вне рамок этой +итерации. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# За сколько дней до протухания кук предупреждать (зеркалит #2658/#2674). Обновление +# кук — РУЧНАЯ операция (залить дамп через админку), человеку нужен запас: сигнал по +# факту протухания приходит, когда сбор уже встал. save_session ставит ttl 30 дней. +COOKIE_EXPIRY_WARN_DAYS = 5 + +# Cookies, реально наблюдаемые в авторизованной Yandex-сессии (замер #3192, +# 2026-08-28), все HttpOnly кроме отмеченных. +# +# ВАЖНО: это НЕ allowlist для фильтрации того, что сохранять (в отличие от +# CIAN_REQUIRED_COOKIES / DOMCLICK_REQUIRED_COOKIES) — используется ТОЛЬКО в +# has_auth_marker() для проверки "дамп вообще похож на авторизованную сессию". +# Замер #3192 сделан на ПОЛНОМ наборе из 44 cookies, какая именно cookie +# существенна для mainPhone-раскрытия — неизвестно, поэтому save_session хранит +# весь дамп целиком (за вычетом аналитики), а не только эти имена. НЕ сужай +# набор, который реально сохраняется, до этого списка. +YANDEX_CRITICAL_COOKIES: set[str] = { + "Session_id", + "sessionid2", + "sessar", + "i", + "L", + "sessguard", + "yandexuid", # не HttpOnly + "yandex_login", # не HttpOnly +} + +# Ключевая auth-cookie, которую ставит Яндекс.Паспорт при логине — её отсутствие +# в дампе означает "это анонимная сессия", независимо от того, сколько прочих +# cookies присутствует. +AUTH_MARKER_COOKIE = "Session_id" + +# Префиксы имён cookies, которые ВСЕГДА аналитика (Яндекс.Метрика / РСЯ / +# анти-спам виджет), а не auth-состояние — единственное, что выкидываем из +# дампа перед сохранением. Всё остальное сохраняется как пришло. +ANALYTICS_COOKIE_PREFIXES: tuple[str, ...] = ("_ym_", "yabs-", "_yasc") + + +def filter_analytics_cookies(cookies: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Drop только заведомую аналитику (_ym_*, yabs-*, _yasc*) — остальное как есть. + + В отличие от Cian/DomClick здесь нет allowlist на сохранение: замер #3192 + сделан на ПОЛНОМ наборе из 44 cookies, значимость конкретной cookie для + mainPhone-раскрытия неизвестна. Сужать этот фильтр до allowlist известных + "критичных" имён — значит потерять часть замера у следующего инженера, + который решит его "оптимизировать". YANDEX_CRITICAL_COOKIES используется + только в has_auth_marker(), не здесь. + """ + return [c for c in cookies if not str(c.get("name", "")).startswith(ANALYTICS_COOKIE_PREFIXES)] + + +def has_auth_marker(cookies: list[dict[str, Any]]) -> bool: + """True если в дампе присутствует ключевая auth-cookie (Session_id). + + Используется как дешёвая пре-проверка "это вообще похоже на авторизованный + дамп", НЕ полноценная верификация (см. модульный docstring — verify_session + для Yandex вне рамок этой итерации). + """ + names = {c.get("name") for c in cookies} + return AUTH_MARKER_COOKIE in names + + +def save_session( + db: Session, + account_label: str, + cookies: list[dict[str, Any]], + ttl_days: int = 30, +) -> None: + """Encrypt cookies via pgp_sym_encrypt and UPSERT into yandex_session_cookies. + + cookies — список полных CDP cookie-объектов (name/value/domain/path/httpOnly/ + secure/expires), НЕ плоский dict[name, value] как у Cian/DomClick — инъекция + кук в browser context требует domain/path. + + Uses settings.cookie_encryption_key as the encryption secret. + Никогда не логирует сырые значения cookies. + """ + cookies_json = json.dumps(cookies) + db.execute( + text(""" + INSERT INTO yandex_session_cookies ( + account_label, + cookies_encrypted, + expires_at_estimate, + uploaded_at + ) VALUES ( + :label, + pgp_sym_encrypt(:cookies_json, :key), + NOW() + (CAST(:ttl_days AS int) || ' days')::interval, + NOW() + ) + ON CONFLICT (account_label) DO UPDATE SET + cookies_encrypted = EXCLUDED.cookies_encrypted, + expires_at_estimate = EXCLUDED.expires_at_estimate, + uploaded_at = NOW(), + last_invalid_at = NULL + """), + { + "label": account_label, + "cookies_json": cookies_json, + "key": settings.cookie_encryption_key, + "ttl_days": ttl_days, + }, + ) + db.commit() + logger.info( + "Yandex cookies saved for label=%s (count=%d, ttl=%d days)", + account_label, + len(cookies), + ttl_days, + ) + + +def load_session(db: Session) -> list[dict[str, Any]] | None: + """Load most-recently-uploaded valid Yandex cookies (decrypt). + + Returns список CDP cookie-объектов или None если нет валидной session. + Выбирает только записи где expires_at_estimate > NOW() и сессия не была + инвалидирована после последнего upload'а. + """ + row = ( + db.execute( + text(""" + SELECT + account_label, + pgp_sym_decrypt(cookies_encrypted, :key)::text AS cookies_json, + expires_at_estimate + FROM yandex_session_cookies + WHERE expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at) + ORDER BY uploaded_at DESC + LIMIT 1 + """), + {"key": settings.cookie_encryption_key}, + ) + .mappings() + .first() + ) + + if row is None: + logger.warning("No valid Yandex session cookies in DB") + return None + + cookies: list[dict[str, Any]] = json.loads(row["cookies_json"]) + + # Обновляем last_used_at — не критично, игнорируем ошибки. + try: + db.execute( + text( + "UPDATE yandex_session_cookies SET last_used_at = NOW()" + " WHERE account_label = :label" + ), + {"label": row["account_label"]}, + ) + db.commit() + except Exception as exc: + # Ошибка на execute() оставляет сессию в aborted-транзакции (psycopg/PG: + # "current transaction is aborted") — если db переживёт этот вызов (caller + # держит ту же сессию дальше, напр. admin.py session-status делает + # session_expires_at(db, ...) сразу следом), последующие запросы иначе все + # падали бы с той же ошибкой (зеркалит app.services.proxy_egress паттерн). + try: + db.rollback() + except Exception: + logger.warning("Failed to rollback after last_used_at update failure", exc_info=True) + logger.warning("Failed to update last_used_at for label=%s: %s", row["account_label"], exc) + + logger.info( + "Yandex cookies loaded for label=%s (count=%d)", + row["account_label"], + len(cookies), + ) + return cookies + + +def session_expires_at(db: Session, *, valid_only: bool = False) -> datetime | None: + """Когда протухают самые свежезагруженные куки (зеркалит cian_session #2658). + + `load_session` отбирает только ещё валидные записи (expires_at_estimate > NOW()) и на + протухших отдаёт None — вызывающий не мог отличить «кук никогда не загружали» от + «протухли позавчера» и не мог предупредить ЗАРАНЕЕ. + + valid_only=False (диагностика после None от load_session) — свежайшая запись любая: + валидных по определению нет, нужен именно срок протухшей. valid_only=True — та же + запись, которую взял бы load_session: для предупреждения «скоро протухнут» нужен срок + ИМЕННО используемых кук, иначе при нескольких аккаунтах посчитаем по чужой строке. + """ + row = db.execute( + text( + """ + SELECT expires_at_estimate FROM yandex_session_cookies + WHERE NOT CAST(:valid_only AS boolean) + OR (expires_at_estimate > NOW() + AND (last_invalid_at IS NULL OR last_invalid_at < uploaded_at)) + ORDER BY uploaded_at DESC + LIMIT 1 + """ + ), + {"valid_only": valid_only}, + ).first() + if row is None: + return None + expires_at: datetime | None = row[0] + return expires_at + + +def mark_session_invalid(db: Session, account_label: str) -> None: + """Flag session как expired/invalid (например после логаута во время scrape).""" + db.execute( + text( + "UPDATE yandex_session_cookies SET last_invalid_at = NOW() WHERE account_label = :label" + ), + {"label": account_label}, + ) + db.commit() + logger.warning("Yandex session marked invalid for label=%s", account_label) diff --git a/tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql b/tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql new file mode 100644 index 00000000..949db244 --- /dev/null +++ b/tradein-mvp/backend/data/sql/274_yandex_session_cookies.sql @@ -0,0 +1,48 @@ +-- 274_yandex_session_cookies.sql +-- Purpose: Encrypted storage for Yandex.Nedvizhimost browser session cookies. +-- Замер на проде (сайдкар, две карточки, вперемежку с/без кук, 2026-08-28): +-- авторизованная сессия отдаёт mainPhone=1 и на 11 больше уникальных +-- телефонов продавца (90→101 / 89→100) по сравнению с анонимным запросом — +-- то есть контакты продавца физически недоступны без валидной сессии. +-- Uses pgcrypto pgp_sym_encrypt for AES encryption at rest (зеркалит +-- 027_cian_session_cookies.sql / 174_domclick_session_cookies.sql). +-- Dependencies: +-- - pgcrypto extension (installed here via CREATE EXTENSION IF NOT EXISTS) +-- Deploy order: Apply after 273. +-- +-- Security notes: +-- - cookies_encrypted stores AES-encrypted JSON blob via pgp_sym_encrypt. +-- - Encryption key lives in .env.runtime (COOKIE_ENCRYPTION_KEY), never in DB. +-- - Access restricted to admin-token-gated API endpoint only. +-- +-- Schema note: ключ записи — account_label (text), НЕ числовой id как у Cian +-- (account_user_id) / DomClick (account_cas_id) — у Яндекса нет внутреннего +-- числового id аккаунта, доступного из cookie-дампа; используем произвольную +-- текстовую метку аккаунта, которую задаёт оператор при заливке дампа. +-- +-- Sources: issue #3192 (Yandex session cookies — mainPhone/contact reveal) + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS yandex_session_cookies ( + account_label text PRIMARY KEY, -- произвольная метка аккаунта (нет числового id) + cookies_encrypted bytea NOT NULL, -- pgp_sym_encrypt(json_cookies, key) + expires_at_estimate timestamptz NOT NULL, -- estimated expiry (~30 days from upload) + uploaded_at timestamptz NOT NULL DEFAULT NOW(), + last_used_at timestamptz, -- set on each successful authenticated fetch + last_invalid_at timestamptz, -- set when session no longer authenticated + notes text -- e.g. 'Account: