From ff0a15d443fe0cbd2cb25aad971642a60043da64 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 22:53:11 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/browser):=20=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=20=D0=94=D0=BE=D0=BC=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D1=83=20=D0=BA=D0=B0=D0=BA=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D0=B5=D0=BA=20=E2=80=94=20=D1=81=20Referer?= =?UTF-8?q?=20=D0=B8=20=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D1=88=D0=B5=D0=B3=D0=BE=20=D1=80=D1=83=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=B6=D0=B0=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добор карточек Домклика упирался в отказ на 11-й карточке: прогон 5298 дал attempted=13, enriched=10, blocked=3. Ручные прогоны в живом браузере брали 91 и 40 карточек без единого отказа. Разбор нашёл два отличия, и оба оказались нашими, а не площадки. 1. Referer не отправлялся НИКОГДА. playwright'овский goto() по умолчанию этот заголовок не шлёт, а параметр `referer`, который он принимает, мы не передавали. Площадка видела десяток появлений подряд прямо на URL карточки, без источника перехода — так не ходит ни один человек. Комментарии в коде при этом уверяли про «органическую навигацию с реальным Referer»; они врали, теперь исправлены. Сайдкар принимает `referer` в теле /fetch и ставит его ТОЛЬКО на целевую навигацию; на origin и якорную вкладку не ставит — туда приходят «сами». Добор Домклика передаёт страницу выдачи, чем переход и является по смыслу. 2. Зависшее рукопожатие не перезагружалось. _wait_out_pow_challenge построен на допущении «страница перезагрузит себя сама после решения PoW»; ручная сессия 29.08 через узел 10 это опровергла — выдача осталась на 401, и пропуск qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных руками: 102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, следом кука-пропуск, и карточка за 6 секунд. Пока мы только опрашивали content(), такая страница жила до таймаута, а бэкфилл засчитывал это в блоки. Теперь после BROWSER_CHALLENGE_RELOAD_AFTER_MS (8с) сайдкар перезагружает сам, не больше BROWSER_CHALLENGE_MAX_RELOADS (2) раз за фетч. Оба пути безопасны на откат: без поля `referer` в теле поведение прежнее, BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает прежний опрос без навигаций, упавшая перезагрузка не роняет фетч — опрос продолжается в том же бюджете. Тесты: 191 passed в сайдкаре (было 182) — 4 на Referer, 5 на перезагрузку, в том числе «страница ожила сама → лишней навигации нет» и «висит вечно → не больше лимита». Точечные backend-тесты домклика и scraper_kit — 144 passed. --- .../tests/scrapers/test_domclick_detail.py | 3 + .../tests/test_3118_domclick_warm_context.py | 4 +- tradein-mvp/browser/server.py | 104 ++++++++++-- tradein-mvp/browser/test_server.py | 1 + .../browser/test_server_challenge_reload.py | 143 ++++++++++++++++ .../browser/test_server_pow_challenge.py | 7 + tradein-mvp/browser/test_server_referer.py | 153 ++++++++++++++++++ .../src/scraper_kit/browser_fetcher.py | 25 ++- .../scraper_kit/providers/domclick/detail.py | 5 +- 9 files changed, 421 insertions(+), 24 deletions(-) create mode 100644 tradein-mvp/browser/test_server_challenge_reload.py create mode 100644 tradein-mvp/browser/test_server_referer.py diff --git a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py index ddeed11b..92346cf6 100644 --- a/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py +++ b/tradein-mvp/backend/tests/scrapers/test_domclick_detail.py @@ -340,6 +340,7 @@ async def test_fetch_detail_parses_via_browser() -> None: bf.fetch.assert_called_once_with( _CARD_URL, origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", + referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", cookies=None, ) @@ -361,6 +362,7 @@ async def test_fetch_detail_passes_derived_vtorichka_origin() -> None: bf.fetch.assert_called_once_with( other_card_url, origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka", + referer="https://spb.domclick.ru/pokupka/kvartiry/vtorichka", cookies=None, ) @@ -382,6 +384,7 @@ async def test_fetch_detail_passes_cookies_through_to_browser_fetcher() -> None: bf.fetch.assert_called_once_with( _CARD_URL, origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", + referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka", cookies=session_cookies, ) diff --git a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py index 392d7f93..fb96e3cc 100644 --- a/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py +++ b/tradein-mvp/backend/tests/test_3118_domclick_warm_context.py @@ -239,7 +239,7 @@ async def test_3118_pending_reset_consumed_exactly_once(): bf = _fetcher(reuse_context=True) seen: list[bool] = [] - async def _fake_post(url, origin=None, cookies=None, reset_context=False): + async def _fake_post(url, origin=None, cookies=None, reset_context=False, referer=None): seen.append(reset_context) return "" @@ -261,7 +261,7 @@ async def test_3118_pending_reset_survives_transport_retry(): bf = _fetcher(reuse_context=True) seen: list[bool] = [] - async def _fake_post(url, origin=None, cookies=None, reset_context=False): + async def _fake_post(url, origin=None, cookies=None, reset_context=False, referer=None): seen.append(reset_context) if len(seen) == 1: raise httpx.ConnectError("boom") diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 8fd66c4e..2c9f7b92 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -5,7 +5,7 @@ экспонирует простой HTTP API на базе aiohttp: GET /health → {"status": "ok", "browsers": {"avito": bool, ...}} - POST /fetch → {"url","origin"(опц.),"cookies"(опц.)} → {"html": "...", + POST /fetch → {"url","origin"(опц.),"referer"(опц.),"cookies"(опц.)} → {"html": "...", "status": } # status — HTTP-код целевой навигации POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"} POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]} @@ -162,6 +162,22 @@ BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000")) # не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе. BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) +# Через сколько мс безрезультатного опроса перезагрузить страницу САМИМ, не дожидаясь +# её самопроизвольного reload'а. Допущение «страница перезагрузит себя сама» (см. +# _content_during_navigation) верно НЕ всегда: ручная сессия 2026-08-29 через узел 10 +# показала, что после решения PoW выдача Домклика так и осталась на 401, и пропуск +# qrator_jsid2 выдался только после ДВУХ перезагрузок, сделанных человеком руками — +# 102.7с GET → 401, 115.1с GET → 401, 118.4с GET → 200, и сразу за ним кука-пропуск. +# Пока мы только опрашивали content(), такая страница висела до самого таймаута. +# Перезагрузка повторяет ровно то, что делает человек, и стоит одну навигацию. +BROWSER_CHALLENGE_RELOAD_AFTER_MS: int = int( + os.environ.get("BROWSER_CHALLENGE_RELOAD_AFTER_MS", "8000") +) +# Сколько таких перезагрузок допустимо за один фетч. Ноль = прежнее поведение (только +# опрос). Два — столько понадобилось человеку; больше похоже уже на долбёжку, которая +# сама по себе повод отказать. +BROWSER_CHALLENGE_MAX_RELOADS: int = int(os.environ.get("BROWSER_CHALLENGE_MAX_RELOADS", "2")) + # /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало: # первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/ # сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом). @@ -951,12 +967,16 @@ async def fetch_handler(request: web.Request) -> web.Response: return web.json_response({"error": "missing 'url' field"}, status=400) # origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url, - # чтобы получить пропуск QRATOR в куки контекста (Referer тут ни при чём: goto() - # его не шлёт; зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие - # от fetch-json) — caller не передал → остаётся None, origin-goto не выполняется, - # поведение идентично прежнему (avito/cian/yandex его никогда не передают). + # чтобы получить пропуск QRATOR в куки контекста (зеркалит /fetch-json #1917). + # Явного auto-derive из url здесь НЕТ (в отличие от fetch-json) — caller не передал + # → остаётся None, origin-goto не выполняется, поведение идентично прежнему + # (avito/cian/yandex его никогда не передают). origin: str | None = body.get("origin") + # referer (опционально, #3247) — HTTP Referer, передаётся ТОЛЬКО в goto() целевого + # url (см. _fetch_once). Поле отсутствует → None → поведение идентично прежнему. + referer: str | None = body.get("referer") + # cookies (опционально) — dict name→value для инъекции в контекст страницы ПЕРЕД # навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, # эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не @@ -1012,6 +1032,7 @@ async def fetch_handler(request: web.Request) -> web.Response: provider, url, origin=origin, + referer=referer, cookies=cookies, reuse_context=reuse_context, reset_context=reset_context, @@ -1185,6 +1206,7 @@ async def _do_fetch( url: str, *, origin: str | None = None, + referer: str | None = None, cookies: dict | None = None, reuse_context: bool = False, reset_context: bool = False, @@ -1198,14 +1220,15 @@ async def _do_fetch( провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа, а не обратится к context'у уже мёртвого браузера. - origin/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не меняют - поведение. + origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не + меняют поведение. """ try: return await _fetch_once( provider, url, origin=origin, + referer=referer, cookies=cookies, reuse_context=reuse_context, reset_context=reset_context, @@ -1225,6 +1248,7 @@ async def _do_fetch( provider, url, origin=origin, + referer=referer, cookies=cookies, reuse_context=reuse_context, reset_context=reset_context, @@ -1395,12 +1419,20 @@ async def _wait_out_pow_challenge( ) -> str: """Опрашивает page.content() пока `is_pending(html)` не станет False. - Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после - решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится; + Страница ОБЫЧНО перезагружает себя сама (`window.location = location.href`) + после решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится; опрашиваем контент с шагом ~1с вместо этого. По истечении BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача challenge-страницы как будто это валидный контент. + «Обычно» — не «всегда», и на этом мы горели: ручная сессия 2026-08-29 показала + выдачу Домклика, которая после решения PoW осталась на 401 и отдала контент лишь + после двух перезагрузок, сделанных человеком руками. Пока мы только опрашивали + content(), такая страница гарантированно доживала до таймаута. Поэтому после + BROWSER_CHALLENGE_RELOAD_AFTER_MS безрезультатного опроса перезагружаем сами, до + BROWSER_CHALLENGE_MAX_RELOADS раз — ровно то действие, которым человек и вышел из + этого состояния. Reload не отменяет ожидания: опрос продолжается в том же бюджете. + `is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих провайдеров не меняется) — предикат «страница ещё не устоялась, опроси снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного @@ -1412,10 +1444,43 @@ async def _wait_out_pow_challenge( """ poll_interval_ms = 1000 elapsed_ms = 0 + reloads = 0 + next_reload_at_ms = BROWSER_CHALLENGE_RELOAD_AFTER_MS html: str | None = await _content_during_navigation(page) while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS: await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] elapsed_ms += poll_interval_ms + if ( + reloads < BROWSER_CHALLENGE_MAX_RELOADS + and BROWSER_CHALLENGE_RELOAD_AFTER_MS > 0 + and elapsed_ms >= next_reload_at_ms + ): + reloads += 1 + next_reload_at_ms = elapsed_ms + BROWSER_CHALLENGE_RELOAD_AFTER_MS + logger.info( + "tradein-browser[%s]: челлендж висит %dмс — перезагружаю страницу " + "(%d/%d) url=%r", + provider, + elapsed_ms, + reloads, + BROWSER_CHALLENGE_MAX_RELOADS, + url, + ) + try: + await page.reload( # type: ignore[attr-defined] + timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" + ) + except Exception as exc: + # Перезагрузка — попытка помочь, а не обязательный шаг. Упала (гонка + # с собственным reload'ом страницы, таймаут навигации) — продолжаем + # опрос в прежнем бюджете, а не роняем фетч, который мог бы устояться. + logger.warning( + "tradein-browser[%s]: перезагрузка челленджа не удалась (%s: %s) — " + "продолжаю опрос", + provider, + type(exc).__name__, + exc, + ) html = await _content_during_navigation(page) if html is None or is_pending(html): @@ -1482,6 +1547,7 @@ async def _fetch_once( url: str, *, origin: str | None = None, + referer: str | None = None, cookies: dict | None = None, reuse_context: bool = False, reset_context: bool = False, @@ -1494,14 +1560,18 @@ async def _fetch_once( origin (опционально) — same-site якорь (например SERP), на который камуфокс заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит _fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую - 2026-07-04). Работает это через КУКИ контекста, а не через Referer: goto() - заголовок Referer не шлёт вовсе, так что «органической навигации» тут никогда не - было — ценность ровно в прогретом контексте. Поэтому при reuse_context origin + 2026-07-04). Работает это через КУКИ контекста, поэтому при reuse_context origin поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия. None (дефолт) → поведение не меняется, ровно один goto(url) как раньше (avito/cian/yandex не передают origin). + referer (опционально) — HTTP-заголовок Referer, передаётся playwright'у ТОЛЬКО + для целевой навигации ``goto(url, referer=...)`` — площадка видит переход "пришёл + из выдачи", а не голый заход прямо на URL карточки (#3247). На origin/якорную + вкладку referer НЕ передаётся: туда камуфокс приходит "сам", без источника. + None (дефолт) → page.goto(url) без referer, поведение идентично прежнему. + cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации (обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а @@ -1569,9 +1639,13 @@ async def _fetch_once( await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] # Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует # ответ площадки на запрошенный url (#3196). - response = await page.goto( # type: ignore[attr-defined] - url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" - ) + goto_kwargs: dict[str, object] = { + "timeout": BROWSER_NAV_TIMEOUT_MS, + "wait_until": "domcontentloaded", + } + if referer: + goto_kwargs["referer"] = referer + response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined] _last_response_status[provider] = _status_of(response) if BROWSER_WAIT_MS > 0: await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] diff --git a/tradein-mvp/browser/test_server.py b/tradein-mvp/browser/test_server.py index 6a1ae2db..226d3da5 100644 --- a/tradein-mvp/browser/test_server.py +++ b/tradein-mvp/browser/test_server.py @@ -178,6 +178,7 @@ class _OverlapProbe: url: str, *, origin: str | None = None, + referer: str | None = None, cookies: dict | None = None, reuse_context: bool = False, reset_context: bool = False, diff --git a/tradein-mvp/browser/test_server_challenge_reload.py b/tradein-mvp/browser/test_server_challenge_reload.py new file mode 100644 index 00000000..6a36f658 --- /dev/null +++ b/tradein-mvp/browser/test_server_challenge_reload.py @@ -0,0 +1,143 @@ +"""test_server_challenge_reload.py — сайдкар сам перезагружает зависший PoW-челлендж. + +Повод — ручная сессия 2026-08-29 через узел 10 (asocks-mobile-2, прод-прокси, +headful-камуфокс, путь «яндекс-поиск → выдача Домклика → карточка»). Запись сети: + + 102.7с GET /pokupka/kvartiry/vtorichka → 401 + 107.7с выдана кука qrator_jsr (PoW пошёл) + 115.1с GET /pokupka/kvartiry/vtorichka → 401 ← перезагрузка человеком + 118.4с GET /pokupka/kvartiry/vtorichka → 200 ← вторая перезагрузка + 119.6с выдана кука qrator_jsid2 (ПРОПУСК) + 124.6с GET /card/sale__flat__2078134349 → 200 + +То есть допущение «страница перезагрузит себя сама после решения PoW», на котором +был построен _wait_out_pow_challenge, выполняется НЕ всегда: здесь пропуск выдался +только после навигаций, сделанных руками. Пока сайдкар лишь опрашивал content(), +такая страница гарантированно доживала до BROWSER_CHALLENGE_WAIT_MS и уходила в +ChallengeTimeoutError, а бэкфилл засчитывал это в блоки. + +Здесь проверяется, что сайдкар делает то же, что сделал человек: подождав +BROWSER_CHALLENGE_RELOAD_AFTER_MS, перезагружает страницу сам, до +BROWSER_CHALLENGE_MAX_RELOADS раз, и что это не ломает прежние пути. + +camoufox НЕ запускается: поддельная page с заданной последовательностью content(). +wait_for_timeout — no-op, поэтому тесты идут мгновенно. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_challenge_reload.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) + + +PENDING = "проверка безопасности" +DONE = "listing card content" + + +class _Page: + """Поддельная page: content() идёт по списку, reload() двигает курсор дальше. + + `reload_unblocks_at` — индекс, начиная с которого content() отдаёт DONE, но + ТОЛЬКО если к этому моменту была хотя бы одна перезагрузка. Так моделируется + именно наблюдавшееся поведение: сама по себе страница не «дозревает», её + расколдовывает повторная навигация. + """ + + def __init__(self, *, unblock_after_reloads: int | None = None) -> None: + self.reload_calls: list[dict[str, Any]] = [] + self.wait_for_timeout_calls: list[int] = [] + self._unblock_after_reloads = unblock_after_reloads + self.reload_error: Exception | None = None + + async def wait_for_timeout(self, ms: int) -> None: + self.wait_for_timeout_calls.append(ms) + + async def reload(self, **kwargs: Any) -> None: + if self.reload_error is not None: + raise self.reload_error + self.reload_calls.append(kwargs) + + async def content(self) -> str: + if ( + self._unblock_after_reloads is not None + and len(self.reload_calls) >= self._unblock_after_reloads + ): + return DONE + return PENDING + + +@pytest.fixture(autouse=True) +def _fast_budget(monkeypatch: pytest.MonkeyPatch) -> None: + """Короткий бюджет + отключённая догидрация: тест про перезагрузки, не про сон.""" + monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 30_000) + monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 8_000) + monkeypatch.setattr(server, "BROWSER_CHALLENGE_MAX_RELOADS", 2) + monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + + +def _wait(page: _Page) -> str: + return asyncio.run( + server._wait_out_pow_challenge( + page, "domclick", "https://example.test/card/1", is_pending=lambda h: h == PENDING + ) + ) + + +def test_hung_challenge_is_reloaded_and_resolves() -> None: + """Главный сценарий: сама страница не оживает, оживает после перезагрузки.""" + page = _Page(unblock_after_reloads=1) + assert _wait(page) == DONE + assert len(page.reload_calls) == 1 + + +def test_reload_not_used_when_challenge_clears_on_its_own() -> None: + """Страница, которая перезагружает себя сама, лишней навигации не получает.""" + page = _Page(unblock_after_reloads=0) # DONE сразу, ещё до первого опроса + assert _wait(page) == DONE + assert page.reload_calls == [] + + +def test_reload_count_is_capped() -> None: + """Вечно висящий челлендж не превращается в долбёжку — не больше лимита.""" + page = _Page(unblock_after_reloads=None) # не оживает никогда + with pytest.raises(server.ChallengeTimeoutError): + _wait(page) + assert len(page.reload_calls) == server.BROWSER_CHALLENGE_MAX_RELOADS + + +def test_reload_disabled_by_zero_threshold(monkeypatch: pytest.MonkeyPatch) -> None: + """BROWSER_CHALLENGE_RELOAD_AFTER_MS=0 возвращает ровно прежнее поведение.""" + monkeypatch.setattr(server, "BROWSER_CHALLENGE_RELOAD_AFTER_MS", 0) + page = _Page(unblock_after_reloads=None) + with pytest.raises(server.ChallengeTimeoutError): + _wait(page) + assert page.reload_calls == [] + + +def test_failed_reload_does_not_abort_the_wait() -> None: + """Перезагрузка — помощь, а не обязательный шаг: её падение не роняет фетч. + + Ошибка навигации здесь означает лишь, что помочь не удалось; опрос обязан + продолжиться в прежнем бюджете и упереться в честный ChallengeTimeoutError, + а не вывалиться наружу чужим исключением. + """ + page = _Page(unblock_after_reloads=None) + page.reload_error = RuntimeError("Navigation timeout of 30000ms exceeded") + with pytest.raises(server.ChallengeTimeoutError): + _wait(page) + assert page.reload_calls == [] diff --git a/tradein-mvp/browser/test_server_pow_challenge.py b/tradein-mvp/browser/test_server_pow_challenge.py index 79bf1313..6080f875 100644 --- a/tradein-mvp/browser/test_server_pow_challenge.py +++ b/tradein-mvp/browser/test_server_pow_challenge.py @@ -80,6 +80,7 @@ class _ChallengePage: self._html_sequence = html_sequence self._call_count = 0 self.goto_urls: list[str] = [] + self.reload_calls: list[dict[str, Any]] = [] self.wait_for_timeout_calls: list[int] = [] self.closed = 0 @@ -89,6 +90,12 @@ class _ChallengePage: async def goto(self, url: str, **kwargs: Any) -> None: self.goto_urls.append(url) + async def reload(self, **kwargs: Any) -> None: + # Сайдкар сам перезагружает зависший челлендж (#3244-след), поэтому фейковая + # page обязана уметь reload — иначе тесты ловили бы AttributeError в ветке + # помощи и молча уходили в warning вместо проверки поведения. + self.reload_calls.append(kwargs) + async def wait_for_timeout(self, ms: int) -> None: self.wait_for_timeout_calls.append(ms) diff --git a/tradein-mvp/browser/test_server_referer.py b/tradein-mvp/browser/test_server_referer.py new file mode 100644 index 00000000..921ac18b --- /dev/null +++ b/tradein-mvp/browser/test_server_referer.py @@ -0,0 +1,153 @@ +"""test_server_referer.py — HTTP Referer при навигации на целевой url (#3247). + +Проблема: ``page.goto(url)`` playwright по умолчанию НЕ отправляет заголовок +Referer. В проде каждая карточка DomClick запрашивается: сайдкар грузит origin +(выдачу), затем ``goto(card_url)`` — площадка видит переход прямо на URL карточки +без источника. Ручные прогоны с кликами (Referer уходил естественно) брали 91 и 40 +карточек без отказов; прод-добор без Referer упирался в отказ на 11-й. + +Тестируется: + 1. referer передаётся В page.goto() ТОЛЬКО для ЦЕЛЕВОГО url (не для origin/якоря); + 2. без referer в вызове — page.goto(url) без kwarg'а referer (прежнее поведение); + 3. _do_fetch пробрасывает referer сквозь retry-путь один-в-один с _fetch_once. + +camoufox НЕ запускается — browser/page подделаны. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_referer.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, "_anchor_pages", {}) + monkeypatch.setattr(server, "_page_counters", {}) + monkeypatch.setattr(server, "_locks", {}) + monkeypatch.setattr(server, "_last_goto_at", {}) + monkeypatch.setattr( + server, "_RECYCLE_PAGES_BY_PROVIDER", {p: 10_000 for p in server.PROVIDERS} + ) + + +class _FakePage: + """Поддельная page: фиксирует (url, kwargs) каждого goto().""" + + def __init__(self) -> None: + self.goto_calls: list[tuple[str, dict[str, Any]]] = [] + self.closed = 0 + + def is_closed(self) -> bool: + return 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_calls.append((url, kwargs)) + + 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 _FakeBrowser: + def __init__(self) -> None: + self.pages: list[_FakePage] = [] + + async def new_page(self) -> _FakePage: + page = _FakePage() + self.pages.append(page) + return page + + +def test_referer_passed_only_to_target_goto() -> None: + browser = _FakeBrowser() + server._browsers["domclick"] = browser + + html = asyncio.run( + server._fetch_once( + "domclick", + "https://domclick.ru/card/1", + origin="https://domclick.ru/serp", + referer="https://domclick.ru/serp", + ) + ) + + assert "__SSR_STATE__" in html + assert len(browser.pages) == 1 + page = browser.pages[0] + # Два goto: origin (без referer), затем target (с referer). + assert len(page.goto_calls) == 2 + origin_url, origin_kwargs = page.goto_calls[0] + target_url, target_kwargs = page.goto_calls[1] + assert origin_url == "https://domclick.ru/serp" + assert "referer" not in origin_kwargs, "origin-навигация НЕ должна получать referer" + assert target_url == "https://domclick.ru/card/1" + assert target_kwargs.get("referer") == "https://domclick.ru/serp" + + +def test_referer_absent_by_default_no_kwarg() -> None: + """Без referer в вызове — goto(url) без kwarg'а referer вовсе (прежнее поведение).""" + browser = _FakeBrowser() + server._browsers["avito"] = browser + + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1")) + + page = browser.pages[0] + assert len(page.goto_calls) == 1 + _, kwargs = page.goto_calls[0] + assert "referer" not in kwargs + + +def test_referer_none_explicit_no_kwarg() -> None: + """referer=None явно — то же самое: без ключа в kwargs (falsy-check в _fetch_once).""" + browser = _FakeBrowser() + server._browsers["avito"] = browser + + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/2", referer=None)) + + page = browser.pages[0] + _, kwargs = page.goto_calls[0] + assert "referer" not in kwargs + + +def test_do_fetch_passes_referer_through() -> None: + """_do_fetch (обёртка с retry) пробрасывает referer в _fetch_once один-в-один.""" + browser = _FakeBrowser() + server._browsers["domclick"] = browser + + asyncio.run( + server._do_fetch( + "domclick", + "https://domclick.ru/card/9", + referer="https://domclick.ru/serp", + ) + ) + + page = browser.pages[0] + _, kwargs = page.goto_calls[0] + assert kwargs.get("referer") == "https://domclick.ru/serp" 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 f05a1a21..9476e72f 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 @@ -465,15 +465,21 @@ class BrowserFetcher: url: str, *, origin: str | None = None, + referer: str | None = None, cookies: dict[str, str] | None = None, reset_context: bool = False, ) -> str: """Запрашивает HTML страницы через tradein-browser HTTP-сервис. - origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (органическая - навигация: реальные cookies/Referer вместо холодного goto), см. /fetch - ``origin`` в server.py. None (дефолт) → поведение не меняется (ровно один - goto(url), как раньше) — таков путь всех providers кроме domclick detail. + origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (прогревает + cookies контекста ДО холодного goto на url), см. /fetch ``origin`` в + server.py. None (дефолт) → поведение не меняется (ровно один goto(url), как + раньше) — таков путь всех providers кроме domclick detail. + + referer (#3247) — HTTP-заголовок Referer для целевой навигации ``goto(url, + referer=...)`` на сайдкаре: без него голый ``page.goto()`` Referer не шлёт + вовсе, и площадка видит переход прямо на URL карточки без источника. None + (дефолт) → поле не кладётся в payload, поведение не меняется. cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт @@ -513,7 +519,7 @@ class BrowserFetcher: self._context_reset_pending = False try: - return await self._post_fetch(url, origin, cookies, effective_reset) + return await self._post_fetch(url, origin, cookies, effective_reset, referer) except (httpx.HTTPError, httpx.TransportError) as exc: logger.warning( "BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s", @@ -522,7 +528,7 @@ class BrowserFetcher: url, ) await asyncio.sleep(_RETRY_SLEEP_S) - return await self._post_fetch(url, origin, cookies, effective_reset) + return await self._post_fetch(url, origin, cookies, effective_reset, referer) async def fetch_json( self, @@ -791,6 +797,7 @@ class BrowserFetcher: origin: str | None = None, cookies: dict[str, str] | None = None, reset_context: bool = False, + referer: str | None = None, ) -> str: """Один HTTP POST к /fetch эндпоинту сервиса. @@ -798,6 +805,10 @@ class BrowserFetcher: _post_fetch_json, сервер (body.get("origin")/body.get("cookies")) корректно обрабатывает оба случая. + referer (#3247) кладётся в payload ТОЛЬКО когда задан — старый сайдкар без + этого поля в схеме не должен получать неожиданный ключ, а поведение при + referer=None (дефолт) остаётся байт-в-байт прежним. + reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный self._reuse_context включён — при выключенном флаге (дефолт) payload остаётся БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в @@ -825,6 +836,8 @@ class BrowserFetcher: if self._reuse_context: payload["reuse_context"] = True payload["reset_context"] = reset_context + if referer: + payload["referer"] = referer if proxy_url: payload["proxy"] = proxy_url if proxy_kind: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py index 08a5cf07..27a7484f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/detail.py @@ -535,7 +535,10 @@ async def fetch_detail( try: # Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность # холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04). - html = await browser_fetcher.fetch(card_url, origin=origin, cookies=cookies) + # referer=origin (#3247): переход на карточку логически и есть переход из + # выдачи — сайдкар пробрасывает его в page.goto(card_url, referer=origin), + # чего page.goto() без явного referer не делает никогда. + html = await browser_fetcher.fetch(card_url, origin=origin, referer=origin, cookies=cookies) # #3196: статус читаем СРАЗУ после await — следующий fetch() его перезапишет. # getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать. status = getattr(browser_fetcher, "last_response_status", None) -- 2.45.3