Merge pull request 'feat(tradein/browser): ходить по Домклику как человек — с Referer и с перезагрузкой зависшего рукопожатия' (#3250) from feat/browser-referer-on-card-nav into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m11s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Successful in 2m11s
Deploy Trade-In / test (push) Successful in 4m5s
Deploy Trade-In / build-backend (push) Successful in 1m44s
Deploy Trade-In / deploy (push) Successful in 2m11s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
This commit is contained in:
commit
2b62d279fe
9 changed files with 421 additions and 24 deletions
|
|
@ -340,6 +340,7 @@ async def test_fetch_detail_parses_via_browser() -> None:
|
||||||
bf.fetch.assert_called_once_with(
|
bf.fetch.assert_called_once_with(
|
||||||
_CARD_URL,
|
_CARD_URL,
|
||||||
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||||
|
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||||
cookies=None,
|
cookies=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -361,6 +362,7 @@ async def test_fetch_detail_passes_derived_vtorichka_origin() -> None:
|
||||||
bf.fetch.assert_called_once_with(
|
bf.fetch.assert_called_once_with(
|
||||||
other_card_url,
|
other_card_url,
|
||||||
origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
|
origin="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||||
|
referer="https://spb.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||||
cookies=None,
|
cookies=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -382,6 +384,7 @@ async def test_fetch_detail_passes_cookies_through_to_browser_fetcher() -> None:
|
||||||
bf.fetch.assert_called_once_with(
|
bf.fetch.assert_called_once_with(
|
||||||
_CARD_URL,
|
_CARD_URL,
|
||||||
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
origin="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||||
|
referer="https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka",
|
||||||
cookies=session_cookies,
|
cookies=session_cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,7 @@ async def test_3118_pending_reset_consumed_exactly_once():
|
||||||
bf = _fetcher(reuse_context=True)
|
bf = _fetcher(reuse_context=True)
|
||||||
seen: list[bool] = []
|
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)
|
seen.append(reset_context)
|
||||||
return "<html></html>"
|
return "<html></html>"
|
||||||
|
|
||||||
|
|
@ -261,7 +261,7 @@ async def test_3118_pending_reset_survives_transport_retry():
|
||||||
bf = _fetcher(reuse_context=True)
|
bf = _fetcher(reuse_context=True)
|
||||||
seen: list[bool] = []
|
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)
|
seen.append(reset_context)
|
||||||
if len(seen) == 1:
|
if len(seen) == 1:
|
||||||
raise httpx.ConnectError("boom")
|
raise httpx.ConnectError("boom")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
экспонирует простой HTTP API на базе aiohttp:
|
экспонирует простой HTTP API на базе aiohttp:
|
||||||
|
|
||||||
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
|
GET /health → {"status": "ok", "browsers": {"avito": bool, ...}}
|
||||||
POST /fetch → {"url","origin"(опц.),"cookies"(опц.)} → {"html": "...",
|
POST /fetch → {"url","origin"(опц.),"referer"(опц.),"cookies"(опц.)} → {"html": "...",
|
||||||
"status": <int|null>} # status — HTTP-код целевой навигации
|
"status": <int|null>} # status — HTTP-код целевой навигации
|
||||||
POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"}
|
POST /fetch-json → {"url","method","headers","body","origin"} → {"status","body"}
|
||||||
POST /login → {"url": "...", "email": "...", "password": "...", ...} → {"cookies": [...]}
|
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"))
|
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мс мало:
|
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
|
||||||
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
||||||
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
# сетевой стек страницы ещё не готов). Лечился внешним 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)
|
return web.json_response({"error": "missing 'url' field"}, status=400)
|
||||||
|
|
||||||
# origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url,
|
# origin (опционально) — same-site якорь, на который камуфокс зайдёт ПЕРЕД url,
|
||||||
# чтобы получить пропуск QRATOR в куки контекста (Referer тут ни при чём: goto()
|
# чтобы получить пропуск QRATOR в куки контекста (зеркалит /fetch-json #1917).
|
||||||
# его не шлёт; зеркалит /fetch-json #1917). Явного auto-derive из url здесь НЕТ (в отличие
|
# Явного auto-derive из url здесь НЕТ (в отличие от fetch-json) — caller не передал
|
||||||
# от fetch-json) — caller не передал → остаётся None, origin-goto не выполняется,
|
# → остаётся None, origin-goto не выполняется, поведение идентично прежнему
|
||||||
# поведение идентично прежнему (avito/cian/yandex его никогда не передают).
|
# (avito/cian/yandex его никогда не передают).
|
||||||
origin: str | None = body.get("origin")
|
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 для инъекции в контекст страницы ПЕРЕД
|
# cookies (опционально) — dict name→value для инъекции в контекст страницы ПЕРЕД
|
||||||
# навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии,
|
# навигацией (обход QRATOR-блока DomClick при валидной test-аккаунт сессии,
|
||||||
# эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не
|
# эмпирически подтверждено вживую 2026-07-04). Явного auto-derive нет — caller не
|
||||||
|
|
@ -1012,6 +1032,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
provider,
|
provider,
|
||||||
url,
|
url,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
|
referer=referer,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
reuse_context=reuse_context,
|
reuse_context=reuse_context,
|
||||||
reset_context=reset_context,
|
reset_context=reset_context,
|
||||||
|
|
@ -1185,6 +1206,7 @@ async def _do_fetch(
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
origin: str | None = None,
|
origin: str | None = None,
|
||||||
|
referer: str | None = None,
|
||||||
cookies: dict | None = None,
|
cookies: dict | None = None,
|
||||||
reuse_context: bool = False,
|
reuse_context: bool = False,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
|
|
@ -1198,14 +1220,15 @@ async def _do_fetch(
|
||||||
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
|
провайдера (#3118) — на retry reuse_context=True создаст его заново с чистого листа,
|
||||||
а не обратится к context'у уже мёртвого браузера.
|
а не обратится к context'у уже мёртвого браузера.
|
||||||
|
|
||||||
origin/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не меняют
|
origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не
|
||||||
поведение.
|
меняют поведение.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await _fetch_once(
|
return await _fetch_once(
|
||||||
provider,
|
provider,
|
||||||
url,
|
url,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
|
referer=referer,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
reuse_context=reuse_context,
|
reuse_context=reuse_context,
|
||||||
reset_context=reset_context,
|
reset_context=reset_context,
|
||||||
|
|
@ -1225,6 +1248,7 @@ async def _do_fetch(
|
||||||
provider,
|
provider,
|
||||||
url,
|
url,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
|
referer=referer,
|
||||||
cookies=cookies,
|
cookies=cookies,
|
||||||
reuse_context=reuse_context,
|
reuse_context=reuse_context,
|
||||||
reset_context=reset_context,
|
reset_context=reset_context,
|
||||||
|
|
@ -1395,12 +1419,20 @@ async def _wait_out_pow_challenge(
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
|
"""Опрашивает page.content() пока `is_pending(html)` не станет False.
|
||||||
|
|
||||||
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
Страница ОБЫЧНО перезагружает себя сама (`window.location = location.href`)
|
||||||
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
после решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||||
опрашиваем контент с шагом ~1с вместо этого. По истечении
|
опрашиваем контент с шагом ~1с вместо этого. По истечении
|
||||||
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
||||||
challenge-страницы как будто это валидный контент.
|
challenge-страницы как будто это валидный контент.
|
||||||
|
|
||||||
|
«Обычно» — не «всегда», и на этом мы горели: ручная сессия 2026-08-29 показала
|
||||||
|
выдачу Домклика, которая после решения PoW осталась на 401 и отдала контент лишь
|
||||||
|
после двух перезагрузок, сделанных человеком руками. Пока мы только опрашивали
|
||||||
|
content(), такая страница гарантированно доживала до таймаута. Поэтому после
|
||||||
|
BROWSER_CHALLENGE_RELOAD_AFTER_MS безрезультатного опроса перезагружаем сами, до
|
||||||
|
BROWSER_CHALLENGE_MAX_RELOADS раз — ровно то действие, которым человек и вышел из
|
||||||
|
этого состояния. Reload не отменяет ожидания: опрос продолжается в том же бюджете.
|
||||||
|
|
||||||
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
|
`is_pending` (дефолт `_is_pow_challenge`, поведение Авито и прочих
|
||||||
провайдеров не меняется) — предикат «страница ещё не устоялась, опроси
|
провайдеров не меняется) — предикат «страница ещё не устоялась, опроси
|
||||||
снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного
|
снова». DomClick передаёт свой (см. _fetch_once) — там нет отдельного
|
||||||
|
|
@ -1412,10 +1444,43 @@ async def _wait_out_pow_challenge(
|
||||||
"""
|
"""
|
||||||
poll_interval_ms = 1000
|
poll_interval_ms = 1000
|
||||||
elapsed_ms = 0
|
elapsed_ms = 0
|
||||||
|
reloads = 0
|
||||||
|
next_reload_at_ms = BROWSER_CHALLENGE_RELOAD_AFTER_MS
|
||||||
html: str | None = await _content_during_navigation(page)
|
html: str | None = await _content_during_navigation(page)
|
||||||
while (html is None or is_pending(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
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]
|
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||||
elapsed_ms += poll_interval_ms
|
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)
|
html = await _content_during_navigation(page)
|
||||||
|
|
||||||
if html is None or is_pending(html):
|
if html is None or is_pending(html):
|
||||||
|
|
@ -1482,6 +1547,7 @@ async def _fetch_once(
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
origin: str | None = None,
|
origin: str | None = None,
|
||||||
|
referer: str | None = None,
|
||||||
cookies: dict | None = None,
|
cookies: dict | None = None,
|
||||||
reuse_context: bool = False,
|
reuse_context: bool = False,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
|
|
@ -1494,14 +1560,18 @@ async def _fetch_once(
|
||||||
origin (опционально) — same-site якорь (например SERP), на который камуфокс
|
origin (опционально) — same-site якорь (например SERP), на который камуфокс
|
||||||
заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит
|
заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит
|
||||||
_fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую
|
_fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую
|
||||||
2026-07-04). Работает это через КУКИ контекста, а не через Referer: goto()
|
2026-07-04). Работает это через КУКИ контекста, поэтому при reuse_context origin
|
||||||
заголовок Referer не шлёт вовсе, так что «органической навигации» тут никогда не
|
|
||||||
было — ценность ровно в прогретом контексте. Поэтому при reuse_context origin
|
|
||||||
поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается
|
поднимается ОДИН раз в якорной вкладке (_ensure_anchor_page), а не переоткрывается
|
||||||
перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия.
|
перед каждой карточкой: контекст уже прогрет, повторный заход — трата рукопожатия.
|
||||||
None (дефолт) → поведение не меняется, ровно один goto(url) как раньше
|
None (дефолт) → поведение не меняется, ровно один goto(url) как раньше
|
||||||
(avito/cian/yandex не передают origin).
|
(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 для инъекции ДО любой навигации
|
cookies (опционально) — dict cookie_name→value для инъекции ДО любой навигации
|
||||||
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
|
(обход QRATOR-блока DomClick при валидной test-аккаунт сессии, эмпирически
|
||||||
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
|
подтверждено вживую 2026-07-04). Провайдер-агностично: домен НЕ захардкожен, а
|
||||||
|
|
@ -1569,9 +1639,13 @@ async def _fetch_once(
|
||||||
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
|
||||||
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
|
# Статус берём у ЦЕЛЕВОЙ навигации, а не у прогрева origin выше: интересует
|
||||||
# ответ площадки на запрошенный url (#3196).
|
# ответ площадки на запрошенный url (#3196).
|
||||||
response = await page.goto( # type: ignore[attr-defined]
|
goto_kwargs: dict[str, object] = {
|
||||||
url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
"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)
|
_last_response_status[provider] = _status_of(response)
|
||||||
if BROWSER_WAIT_MS > 0:
|
if BROWSER_WAIT_MS > 0:
|
||||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@ class _OverlapProbe:
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
origin: str | None = None,
|
origin: str | None = None,
|
||||||
|
referer: str | None = None,
|
||||||
cookies: dict | None = None,
|
cookies: dict | None = None,
|
||||||
reuse_context: bool = False,
|
reuse_context: bool = False,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
|
|
|
||||||
143
tradein-mvp/browser/test_server_challenge_reload.py
Normal file
143
tradein-mvp/browser/test_server_challenge_reload.py
Normal file
|
|
@ -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 = "<html><body>проверка безопасности</body></html>"
|
||||||
|
DONE = "<html><body>listing card content</body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
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 == []
|
||||||
|
|
@ -80,6 +80,7 @@ class _ChallengePage:
|
||||||
self._html_sequence = html_sequence
|
self._html_sequence = html_sequence
|
||||||
self._call_count = 0
|
self._call_count = 0
|
||||||
self.goto_urls: list[str] = []
|
self.goto_urls: list[str] = []
|
||||||
|
self.reload_calls: list[dict[str, Any]] = []
|
||||||
self.wait_for_timeout_calls: list[int] = []
|
self.wait_for_timeout_calls: list[int] = []
|
||||||
self.closed = 0
|
self.closed = 0
|
||||||
|
|
||||||
|
|
@ -89,6 +90,12 @@ class _ChallengePage:
|
||||||
async def goto(self, url: str, **kwargs: Any) -> None:
|
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||||
self.goto_urls.append(url)
|
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:
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
self.wait_for_timeout_calls.append(ms)
|
self.wait_for_timeout_calls.append(ms)
|
||||||
|
|
||||||
|
|
|
||||||
153
tradein-mvp/browser/test_server_referer.py
Normal file
153
tradein-mvp/browser/test_server_referer.py
Normal file
|
|
@ -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 "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
@ -465,15 +465,21 @@ class BrowserFetcher:
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
origin: str | None = None,
|
origin: str | None = None,
|
||||||
|
referer: str | None = None,
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
|
"""Запрашивает HTML страницы через tradein-browser HTTP-сервис.
|
||||||
|
|
||||||
origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (органическая
|
origin — same-site якорь, на который камуфокс зайдёт ПЕРЕД url (прогревает
|
||||||
навигация: реальные cookies/Referer вместо холодного goto), см. /fetch
|
cookies контекста ДО холодного goto на url), см. /fetch ``origin`` в
|
||||||
``origin`` в server.py. None (дефолт) → поведение не меняется (ровно один
|
server.py. None (дефолт) → поведение не меняется (ровно один goto(url), как
|
||||||
goto(url), как раньше) — таков путь всех providers кроме domclick detail.
|
раньше) — таков путь всех providers кроме domclick detail.
|
||||||
|
|
||||||
|
referer (#3247) — HTTP-заголовок Referer для целевой навигации ``goto(url,
|
||||||
|
referer=...)`` на сайдкаре: без него голый ``page.goto()`` Referer не шлёт
|
||||||
|
вовсе, и площадка видит переход прямо на URL карточки без источника. None
|
||||||
|
(дефолт) → поле не кладётся в payload, поведение не меняется.
|
||||||
|
|
||||||
cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД
|
cookies — dict cookie_name→value для инъекции в browser-контекст ПЕРЕД
|
||||||
навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт
|
навигацией (обходит QRATOR-блок DomClick при валидной test-аккаунт
|
||||||
|
|
@ -513,7 +519,7 @@ class BrowserFetcher:
|
||||||
self._context_reset_pending = False
|
self._context_reset_pending = False
|
||||||
|
|
||||||
try:
|
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:
|
except (httpx.HTTPError, httpx.TransportError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
|
"BrowserFetcher: ошибка запроса (%s), retry через %.1fs: %s",
|
||||||
|
|
@ -522,7 +528,7 @@ class BrowserFetcher:
|
||||||
url,
|
url,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(_RETRY_SLEEP_S)
|
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(
|
async def fetch_json(
|
||||||
self,
|
self,
|
||||||
|
|
@ -791,6 +797,7 @@ class BrowserFetcher:
|
||||||
origin: str | None = None,
|
origin: str | None = None,
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
|
referer: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Один HTTP POST к /fetch эндпоинту сервиса.
|
"""Один HTTP POST к /fetch эндпоинту сервиса.
|
||||||
|
|
||||||
|
|
@ -798,6 +805,10 @@ class BrowserFetcher:
|
||||||
_post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
|
_post_fetch_json, сервер (body.get("origin")/body.get("cookies"))
|
||||||
корректно обрабатывает оба случая.
|
корректно обрабатывает оба случая.
|
||||||
|
|
||||||
|
referer (#3247) кладётся в payload ТОЛЬКО когда задан — старый сайдкар без
|
||||||
|
этого поля в схеме не должен получать неожиданный ключ, а поведение при
|
||||||
|
referer=None (дефолт) остаётся байт-в-байт прежним.
|
||||||
|
|
||||||
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
|
reuse_context/reset_context (#3118) кладутся в payload ТОЛЬКО когда сессионный
|
||||||
self._reuse_context включён — при выключенном флаге (дефолт) payload остаётся
|
self._reuse_context включён — при выключенном флаге (дефолт) payload остаётся
|
||||||
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
|
БАЙТ-В-БАЙТ таким же, как раньше: сайдкар старой версии (без этих полей в
|
||||||
|
|
@ -825,6 +836,8 @@ class BrowserFetcher:
|
||||||
if self._reuse_context:
|
if self._reuse_context:
|
||||||
payload["reuse_context"] = True
|
payload["reuse_context"] = True
|
||||||
payload["reset_context"] = reset_context
|
payload["reset_context"] = reset_context
|
||||||
|
if referer:
|
||||||
|
payload["referer"] = referer
|
||||||
if proxy_url:
|
if proxy_url:
|
||||||
payload["proxy"] = proxy_url
|
payload["proxy"] = proxy_url
|
||||||
if proxy_kind:
|
if proxy_kind:
|
||||||
|
|
|
||||||
|
|
@ -535,7 +535,10 @@ async def fetch_detail(
|
||||||
try:
|
try:
|
||||||
# Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность
|
# Заход на same-site vtorichka-SERP ПЕРЕД карточкой снижает подозрительность
|
||||||
# холодной навигации для QRATOR (эмпирически подтверждено вживую 2026-07-04).
|
# холодной навигации для 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() его перезапишет.
|
# #3196: статус читаем СРАЗУ после await — следующий fetch() его перезапишет.
|
||||||
# getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать.
|
# getattr — сайдкар/фетчер старой сборки атрибута не имеет, это не повод падать.
|
||||||
status = getattr(browser_fetcher, "last_response_status", None)
|
status = getattr(browser_fetcher, "last_response_status", None)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue