feat(tradein/browser): имитация чтения страницы человеком, целиком opt-in (#3283)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m26s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m26s
У сайдкара не было ни одной строки имитации поведения: после page.goto шли
только wait_for_timeout и content(). grep по scroll/mouse/wheel/go_back давал
ноль совпадений.
Новое НЕОБЯЗАТЕЛЬНОЕ поле тела POST /fetch — behaviour:
scroll — 3-6 прокруток колесом с паузами 700-1800мс перед content();
dwell_ms — [min,max] дополнительной случайной паузы;
hop_recommended — заход на ОДНУ соседнюю карточку того же хоста, результат
наружу не идёт, отказ глотается, только при reuse_context.
Поле отсутствует → не исполняется ни одна новая строка. Это осознанно: вклад
поведения в выход НЕ доказан. Замер 31.08 сравнивал «без ротации и без
поведения» против «ротация + поведение», то есть две переменные сразу; ротацию
мы выкатили отдельно, и сама по себе она выход не подняла (53% блоков до, 52%
после). Прежде чем включать поведение навсегда, его надо померить одно — а для
этого дефолт обязан остаться прежним.
Ссылки для hop отбираются в две ступени: CSS-селектор сужает раздел, шаблон URL
отличает карточку от рубрики и футера. Без второй ступени "a[href]" на карточке
Авито увёл бы на главную. Шаблон Авито (путь с /kvartiry/ и id из 7+ цифр) снят
на живом замере 31.08; Домклик — /card/sale__flat__<id> по логам сайдкара.
cian/yandex/generic шаблона не имеют осознанно: их вёрстку я не снимал, а
выдуманный шаблон хуже отсутствующего — при None сужение не применяется.
BROWSER_PAGE_INTERVAL_JITTER_S с дефолтом 0: джиттер пейсинга, в отличие от
остального, телом запроса не гейтится, поэтому ненулевой дефолт сломал бы ту же
измеримость. uniform(0,0)==0.0 — интервал численно прежний, но крутилка есть.
Тесты сайдкара: 231 passed.
This commit is contained in:
parent
9219ab6507
commit
8bb20c05ea
3 changed files with 603 additions and 3 deletions
|
|
@ -141,7 +141,10 @@ import base64
|
||||||
import gzip
|
import gzip
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import re
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
|
from typing import NamedTuple
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
@ -170,6 +173,31 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000
|
||||||
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
||||||
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
||||||
|
|
||||||
|
# ── Имитация чтения человеком (#3283, opt-in через body["behaviour"]) ───────────
|
||||||
|
# Все константы ниже включаются ТОЛЬКО явным behaviour.scroll/dwell_ms/hop_recommended
|
||||||
|
# в теле /fetch — по умолчанию (behaviour отсутствует/пуст) ни одна из них не читается
|
||||||
|
# ни разу, поведение сайдкара байт-в-байт то же, что и до #3283. Это осознанное
|
||||||
|
# требование задачи: вклад имитации в проходимость анти-бота пока НЕ доказан, есть
|
||||||
|
# только косвенные основания, и его нужно честно померить прод-A/B "с behaviour vs
|
||||||
|
# без", а не вкатывать по умолчанию.
|
||||||
|
#
|
||||||
|
# Число и разброс прокруток (500-1400px, 3-6 раз) и пауза между ними (700-1800мс) —
|
||||||
|
# из замера, которым это поведение и мерили (см. PR/issue #3283), не придуманы здесь.
|
||||||
|
BROWSER_SCROLL_MIN_COUNT: int = int(os.environ.get("BROWSER_SCROLL_MIN_COUNT", "3"))
|
||||||
|
BROWSER_SCROLL_MAX_COUNT: int = int(os.environ.get("BROWSER_SCROLL_MAX_COUNT", "6"))
|
||||||
|
BROWSER_SCROLL_MIN_PX: int = int(os.environ.get("BROWSER_SCROLL_MIN_PX", "500"))
|
||||||
|
BROWSER_SCROLL_MAX_PX: int = int(os.environ.get("BROWSER_SCROLL_MAX_PX", "1400"))
|
||||||
|
BROWSER_SCROLL_PAUSE_MIN_MS: int = int(os.environ.get("BROWSER_SCROLL_PAUSE_MIN_MS", "700"))
|
||||||
|
BROWSER_SCROLL_PAUSE_MAX_MS: int = int(os.environ.get("BROWSER_SCROLL_PAUSE_MAX_MS", "1800"))
|
||||||
|
|
||||||
|
# hop_recommended (#3283): отдельный, НАМЕРЕННО короткий таймаут навигации на
|
||||||
|
# рекомендательную ссылку — не наследует BROWSER_NAV_TIMEOUT_MS целиком, чтобы один
|
||||||
|
# залипший побочный заход не съедал бюджет основного /fetch. И пауза "подержать
|
||||||
|
# страницу пару секунд" перед закрытием.
|
||||||
|
BROWSER_HOP_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_HOP_NAV_TIMEOUT_MS", "15000"))
|
||||||
|
BROWSER_HOP_DWELL_MIN_MS: int = int(os.environ.get("BROWSER_HOP_DWELL_MIN_MS", "1500"))
|
||||||
|
BROWSER_HOP_DWELL_MAX_MS: int = int(os.environ.get("BROWSER_HOP_DWELL_MAX_MS", "3000"))
|
||||||
|
|
||||||
# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на
|
# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на
|
||||||
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
|
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
|
||||||
# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером
|
# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером
|
||||||
|
|
@ -222,6 +250,33 @@ FETCH_JSON_LOAD_WAIT_MS: int = int(os.environ.get("FETCH_JSON_LOAD_WAIT_MS", "15
|
||||||
# общем браузере значило бы мешать её сессию с чужими запросами.
|
# общем браузере значило бы мешать её сессию с чужими запросами.
|
||||||
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick")
|
PROVIDERS: tuple[str, ...] = ("avito", "cian", "yandex", "generic", "domclick")
|
||||||
|
|
||||||
|
# Ссылки для behaviour.hop_recommended (#3283) — per-provider, тем же механизмом,
|
||||||
|
# что PROVIDERS/_provider_for_url, БЕЗ хардкода площадки в общем коде хопа
|
||||||
|
# (см. _hop_to_recommended_link).
|
||||||
|
#
|
||||||
|
# ДВЕ ступени, потому что одной CSS-селектора мало: он сужает до нужного раздела,
|
||||||
|
# а отличить карточку объявления от рубрики/пагинации/футера умеет только шаблон
|
||||||
|
# самого URL. Без второй ступени "a[href]" на карточке Авито подберёт навигацию,
|
||||||
|
# хлебные крошки и футер — то есть хоп уводил бы не на соседнее объявление, а на
|
||||||
|
# главную, и смысл следа терялся бы.
|
||||||
|
#
|
||||||
|
# Шаблон Авито снят на живом замере 31.08.2026 (scratchpad/lifetime.py, 39 карточек):
|
||||||
|
# ссылка на объявление о квартире — путь с /kvartiry/ и id из 7+ цифр после "_" в
|
||||||
|
# конце (возможен ?context=...). Домклик — по логам сайдкара того же дня:
|
||||||
|
# /card/sale__flat__<id>.
|
||||||
|
#
|
||||||
|
# cian/yandex/generic шаблона НЕ имеют осознанно: их вёрстку я не снимал, а
|
||||||
|
# выдуманный шаблон хуже отсутствующего — при None вторая ступень не применяется
|
||||||
|
# и работает прежнее поведение (любая ссылка того же хоста).
|
||||||
|
_RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER: dict[str, str] = {p: "a[href]" for p in PROVIDERS}
|
||||||
|
_RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER["avito"] = 'a[href*="/kvartiry/"]'
|
||||||
|
_RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER["domclick"] = 'a[href*="/card/"]'
|
||||||
|
|
||||||
|
_RECOMMENDATION_LINK_PATTERN_BY_PROVIDER: dict[str, re.Pattern[str]] = {
|
||||||
|
"avito": re.compile(r"_\d{7,}(?:\?|$)"),
|
||||||
|
"domclick": re.compile(r"/card/sale__flat__\d+"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(raw: str | None, default: bool) -> bool:
|
def _parse_bool(raw: str | None, default: bool) -> bool:
|
||||||
"""Парсит булев env-флаг ("true"/"1"/"yes"/"on" → True), fallback на default."""
|
"""Парсит булев env-флаг ("true"/"1"/"yes"/"on" → True), fallback на default."""
|
||||||
|
|
@ -261,6 +316,18 @@ _BLOCKED_TYPES: frozenset[str] = _parse_block_types(
|
||||||
# прежде чем открыть новую страницу. 0 = без пейсинга.
|
# прежде чем открыть новую страницу. 0 = без пейсинга.
|
||||||
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
|
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
|
||||||
|
|
||||||
|
# Джиттер поверх интервала пейсинга (#3283) — сейчас пейсинг всегда ждёт РОВНО
|
||||||
|
# BROWSER_MIN_PAGE_INTERVAL_S, без разброса, что само по себе регулярный,
|
||||||
|
# бото-подобный ритм запросов. Дефолт 0 (БЕЗ джиттера) — намеренно: как и
|
||||||
|
# behaviour.* в /fetch, вклад разброса интервала в проходимость анти-бота отдельно
|
||||||
|
# не измерен, а включение по умолчанию сломало бы ту же прод-измеримость "с
|
||||||
|
# джиттером vs без", которую задача защищает для behaviour. Включается явно через
|
||||||
|
# env, без релиза кода — 0 сохраняет прежнее поведение _pace_provider буквально
|
||||||
|
# (interval + random.uniform(0, 0) == interval).
|
||||||
|
BROWSER_PAGE_INTERVAL_JITTER_S: float = float(
|
||||||
|
os.environ.get("BROWSER_PAGE_INTERVAL_JITTER_S", "0")
|
||||||
|
)
|
||||||
|
|
||||||
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
|
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
|
||||||
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
|
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
|
||||||
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
|
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
|
||||||
|
|
@ -1299,6 +1366,46 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# behaviour (опционально, #3283) — имитация чтения страницы человеком: scroll,
|
||||||
|
# dwell_ms, hop_recommended. Отсутствует/пуст → behaviour остаётся None и ни
|
||||||
|
# ОДНА строка нового кода (_apply_scroll_behaviour/_apply_dwell_behaviour/
|
||||||
|
# _hop_to_recommended_link) не исполняется — /fetch ведёт себя байт-в-байт как до
|
||||||
|
# #3283. Это намеренно: вклад имитации в проходимость анти-бота пока НЕ доказан,
|
||||||
|
# и его нужно честно померить прод-A/B "с behaviour vs без" — включать что-либо
|
||||||
|
# из этого по умолчанию значило бы сломать именно этот замер.
|
||||||
|
behaviour_raw = body.get("behaviour") or {}
|
||||||
|
if not isinstance(behaviour_raw, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"'behaviour' должен быть object, получено "
|
||||||
|
f"{type(behaviour_raw).__name__}"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
behaviour: _Behaviour | None = None
|
||||||
|
if behaviour_raw:
|
||||||
|
dwell_ms_raw = behaviour_raw.get("dwell_ms")
|
||||||
|
dwell_ms: tuple[int, int] | None = None
|
||||||
|
if dwell_ms_raw is not None:
|
||||||
|
is_valid_pair = (
|
||||||
|
isinstance(dwell_ms_raw, (list, tuple))
|
||||||
|
and len(dwell_ms_raw) == 2
|
||||||
|
and all(isinstance(v, int) and not isinstance(v, bool) for v in dwell_ms_raw)
|
||||||
|
and dwell_ms_raw[0] >= 0
|
||||||
|
and dwell_ms_raw[1] >= 0
|
||||||
|
and dwell_ms_raw[0] <= dwell_ms_raw[1]
|
||||||
|
)
|
||||||
|
if not is_valid_pair:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"'behaviour.dwell_ms' должен быть [min, max] — два int "
|
||||||
|
f">= 0, min <= max, получено {dwell_ms_raw!r}"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
dwell_ms = (int(dwell_ms_raw[0]), int(dwell_ms_raw[1]))
|
||||||
|
behaviour = _Behaviour(
|
||||||
|
scroll=bool(behaviour_raw.get("scroll", False)),
|
||||||
|
dwell_ms=dwell_ms,
|
||||||
|
hop_recommended=bool(behaviour_raw.get("hop_recommended", False)),
|
||||||
|
)
|
||||||
|
|
||||||
provider = _resolve_provider(body, url)
|
provider = _resolve_provider(body, url)
|
||||||
proxy_override = _resolve_proxy_override(body, provider)
|
proxy_override = _resolve_proxy_override(body, provider)
|
||||||
|
|
||||||
|
|
@ -1340,6 +1447,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
|
||||||
reuse_context=reuse_context,
|
reuse_context=reuse_context,
|
||||||
reset_context=reset_context,
|
reset_context=reset_context,
|
||||||
fetch_mode=fetch_mode,
|
fetch_mode=fetch_mode,
|
||||||
|
behaviour=behaviour,
|
||||||
)
|
)
|
||||||
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
|
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
|
||||||
# запрос этого провайдера успел бы его перезаписать.
|
# запрос этого провайдера успел бы его перезаписать.
|
||||||
|
|
@ -1488,10 +1596,16 @@ async def _pace_provider(provider: str) -> None:
|
||||||
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
|
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
|
||||||
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
|
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
|
||||||
No-op при эффективном интервале провайдера <= 0.
|
No-op при эффективном интервале провайдера <= 0.
|
||||||
|
|
||||||
|
Джиттер (#3283) — BROWSER_PAGE_INTERVAL_JITTER_S добавляется К интервалу на КАЖДЫЙ
|
||||||
|
вызов (``random.uniform(0, JITTER_S)``), а не в остаток ожидания: ровный интервал —
|
||||||
|
сам по себе регулярный, бото-подобный ритм. Дефолт джиттера 0 → uniform(0, 0) == 0.0
|
||||||
|
→ interval не меняется, поведение функции идентично прежнему.
|
||||||
"""
|
"""
|
||||||
interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
|
base_interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
|
||||||
if interval <= 0:
|
if base_interval <= 0:
|
||||||
return
|
return
|
||||||
|
interval = base_interval + random.uniform(0, BROWSER_PAGE_INTERVAL_JITTER_S)
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
last = _last_goto_at.get(provider)
|
last = _last_goto_at.get(provider)
|
||||||
if last is not None:
|
if last is not None:
|
||||||
|
|
@ -1505,6 +1619,116 @@ async def _pace_provider(provider: str) -> None:
|
||||||
_last_goto_at[provider] = asyncio.get_event_loop().time()
|
_last_goto_at[provider] = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
|
||||||
|
class _Behaviour(NamedTuple):
|
||||||
|
"""Разобранное и провалидированное тело body["behaviour"] (#3283, /fetch).
|
||||||
|
|
||||||
|
Собирается ОДИН раз в fetch_handler; None на всём пути ниже (_do_fetch/_fetch_once)
|
||||||
|
означает «behaviour отсутствовал/пуст» — ни одна из функций _apply_scroll_behaviour/
|
||||||
|
_apply_dwell_behaviour/_hop_to_recommended_link не вызывается, поведение то же, что
|
||||||
|
и до #3283.
|
||||||
|
"""
|
||||||
|
|
||||||
|
scroll: bool
|
||||||
|
dwell_ms: tuple[int, int] | None
|
||||||
|
hop_recommended: bool
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_scroll_behaviour(page: object, behaviour: _Behaviour) -> None:
|
||||||
|
"""3-6 прокруток page.mouse.wheel(0, 500-1400px) с паузами 700-1800мс (behaviour.scroll).
|
||||||
|
|
||||||
|
No-op, если behaviour.scroll не выставлен явным True. Числа — именованные модульные
|
||||||
|
константы BROWSER_SCROLL_* (см. их комментарий рядом с BROWSER_WAIT_MS), не хардкод
|
||||||
|
здесь. Не оборачиваем в try/except: вызывается ТОЛЬКО при явном opt-in, отказ здесь
|
||||||
|
(например, страница уже начала закрываться) — тот же класс сбоя навигации, что и
|
||||||
|
остальной _fetch_once, caller (_do_fetch) уже умеет его обрабатывать.
|
||||||
|
"""
|
||||||
|
if not behaviour.scroll:
|
||||||
|
return
|
||||||
|
count = random.randint(BROWSER_SCROLL_MIN_COUNT, BROWSER_SCROLL_MAX_COUNT)
|
||||||
|
for _ in range(count):
|
||||||
|
delta = random.randint(BROWSER_SCROLL_MIN_PX, BROWSER_SCROLL_MAX_PX)
|
||||||
|
await page.mouse.wheel(0, delta) # type: ignore[attr-defined]
|
||||||
|
pause_ms = random.randint(BROWSER_SCROLL_PAUSE_MIN_MS, BROWSER_SCROLL_PAUSE_MAX_MS)
|
||||||
|
await page.wait_for_timeout(pause_ms) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_dwell_behaviour(page: object, behaviour: _Behaviour) -> None:
|
||||||
|
"""Дополнительная случайная пауза [min, max] мс перед content() (behaviour.dwell_ms).
|
||||||
|
|
||||||
|
No-op, если dwell_ms не передан. Диапазон уже провалидирован в fetch_handler
|
||||||
|
(два int >= 0, min <= max) — здесь просто random.randint по нему.
|
||||||
|
"""
|
||||||
|
if behaviour.dwell_ms is None:
|
||||||
|
return
|
||||||
|
low, high = behaviour.dwell_ms
|
||||||
|
await page.wait_for_timeout(random.randint(low, high)) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
async def _hop_to_recommended_link(page: object, provider: str, url: str) -> None:
|
||||||
|
"""Заходит на ОДНУ случайную ссылку того же хоста со страницы (behaviour.hop_recommended).
|
||||||
|
|
||||||
|
Best-effort и полностью изолирован от основного результата /fetch: любой отказ
|
||||||
|
(таймаут, ошибка навигации, бан-страница на побочном заходе) глотается и
|
||||||
|
логируется, наверх ничего не пробрасывается — caller (_fetch_once) вызывает это
|
||||||
|
ПОСЛЕ того, как основной html уже снят и провалидирован.
|
||||||
|
|
||||||
|
Ограничения (см. постановку #3283):
|
||||||
|
* ссылка — ТОЛЬКО с того же host, что и url (никаких внешних переходов);
|
||||||
|
* ссылка != url;
|
||||||
|
* отдельная страница context.new_page() (НЕ переиспользуем ту же page, которую
|
||||||
|
caller вот-вот закроет и чей html уже зафиксирован в ответе);
|
||||||
|
* отдельный, короткий таймаут BROWSER_HOP_NAV_TIMEOUT_MS — не наследует
|
||||||
|
BROWSER_NAV_TIMEOUT_MS целиком, чтобы залипший побочный заход не съедал
|
||||||
|
бюджет основного /fetch.
|
||||||
|
|
||||||
|
Селектор ссылок — per-provider, см. _RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER
|
||||||
|
рядом с PROVIDERS.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
selector = _RECOMMENDATION_LINK_SELECTOR_BY_PROVIDER.get(provider, "a[href]")
|
||||||
|
hrefs: list[str] = await page.locator(selector).evaluate_all( # type: ignore[attr-defined]
|
||||||
|
"els => els.map(el => el.href)"
|
||||||
|
)
|
||||||
|
target_host = (urlparse(url).hostname or "").lower()
|
||||||
|
pattern = _RECOMMENDATION_LINK_PATTERN_BY_PROVIDER.get(provider)
|
||||||
|
candidates = [
|
||||||
|
href
|
||||||
|
for href in hrefs
|
||||||
|
if href
|
||||||
|
and href != url
|
||||||
|
and (urlparse(href).hostname or "").lower() == target_host
|
||||||
|
# Вторая ступень: шаблон URL самой карточки. Нет шаблона для площадки
|
||||||
|
# → не сужаем, поведение как без него.
|
||||||
|
and (pattern is None or pattern.search(href) is not None)
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
logger.debug(
|
||||||
|
"tradein-browser[%s]: hop_recommended — нет ссылок того же хоста url=%r",
|
||||||
|
provider,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
target = random.choice(candidates)
|
||||||
|
hop_page = await page.context.new_page() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
await hop_page.goto( # type: ignore[attr-defined]
|
||||||
|
target, timeout=BROWSER_HOP_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
|
||||||
|
)
|
||||||
|
await hop_page.wait_for_timeout( # type: ignore[attr-defined]
|
||||||
|
random.randint(BROWSER_HOP_DWELL_MIN_MS, BROWSER_HOP_DWELL_MAX_MS)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await hop_page.close() # type: ignore[attr-defined]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"tradein-browser[%s]: hop_recommended отказ (%s: %s) — продолжаю без него url=%r",
|
||||||
|
provider,
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Режимы получения тела (#3264) ────────────────────────────────────────────────
|
# ── Режимы получения тела (#3264) ────────────────────────────────────────────────
|
||||||
# Сайдкар всю жизнь умел ровно одно: navigate — page.goto(url). Для HTML-страницы это
|
# Сайдкар всю жизнь умел ровно одно: navigate — page.goto(url). Для HTML-страницы это
|
||||||
# и есть то, что делает человек. Но два нужных нам ресурса Домклика страницами НЕ
|
# и есть то, что делает человек. Но два нужных нам ресурса Домклика страницами НЕ
|
||||||
|
|
@ -1550,6 +1774,7 @@ async def _do_fetch(
|
||||||
reuse_context: bool = False,
|
reuse_context: bool = False,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
fetch_mode: str = "navigate",
|
fetch_mode: str = "navigate",
|
||||||
|
behaviour: "_Behaviour | None" = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
|
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
|
||||||
|
|
||||||
|
|
@ -1561,7 +1786,9 @@ async def _do_fetch(
|
||||||
а не обратится к context'у уже мёртвого браузера.
|
а не обратится к context'у уже мёртвого браузера.
|
||||||
|
|
||||||
origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не
|
origin/referer/cookies/reuse_context/reset_context — см. _fetch_once. Дефолты не
|
||||||
меняют поведение.
|
меняют поведение. behaviour (#3283) — None (дефолт) → ни одна из
|
||||||
|
_apply_scroll_behaviour/_apply_dwell_behaviour/_hop_to_recommended_link не
|
||||||
|
вызывается, поведение то же, что и до #3283.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return await _fetch_once(
|
return await _fetch_once(
|
||||||
|
|
@ -1573,6 +1800,7 @@ async def _do_fetch(
|
||||||
reuse_context=reuse_context,
|
reuse_context=reuse_context,
|
||||||
reset_context=reset_context,
|
reset_context=reset_context,
|
||||||
fetch_mode=fetch_mode,
|
fetch_mode=fetch_mode,
|
||||||
|
behaviour=behaviour,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_browser_crash(exc):
|
if _is_browser_crash(exc):
|
||||||
|
|
@ -1594,6 +1822,7 @@ async def _do_fetch(
|
||||||
reuse_context=reuse_context,
|
reuse_context=reuse_context,
|
||||||
reset_context=reset_context,
|
reset_context=reset_context,
|
||||||
fetch_mode=fetch_mode,
|
fetch_mode=fetch_mode,
|
||||||
|
behaviour=behaviour,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
@ -1962,12 +2191,20 @@ async def _fetch_once(
|
||||||
reuse_context: bool = False,
|
reuse_context: bool = False,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
fetch_mode: str = "navigate",
|
fetch_mode: str = "navigate",
|
||||||
|
behaviour: "_Behaviour | None" = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
|
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
|
||||||
|
|
||||||
Caller держит _locks[provider], поэтому страницы на этом инстансе не
|
Caller держит _locks[provider], поэтому страницы на этом инстансе не
|
||||||
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
|
параллелятся — recycle через _relaunch_browser безопасен прямо здесь.
|
||||||
|
|
||||||
|
behaviour (#3283, опционально, дефолт None) — имитация чтения человеком:
|
||||||
|
scroll (прокрутки перед content()), dwell_ms (доп. пауза перед content()),
|
||||||
|
hop_recommended (побочный заход на рекомендательную ссылку ПОСЛЕ content(), только
|
||||||
|
fetch_mode="navigate" и reuse_context=True — иначе контекста для него нет).
|
||||||
|
None (дефолт) → ни одна из веток ниже не исполняется, поведение то же, что и до
|
||||||
|
#3283.
|
||||||
|
|
||||||
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, эмпирически подтверждено вживую
|
||||||
|
|
@ -2085,6 +2322,11 @@ async def _fetch_once(
|
||||||
_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]
|
||||||
|
# Имитация чтения (#3283) — ПОСЛЕ успешной навигации и settle, ДО content():
|
||||||
|
# behaviour=None (дефолт) → обе функции no-op, ни одна строка не исполняется.
|
||||||
|
if behaviour is not None:
|
||||||
|
await _apply_scroll_behaviour(page, behaviour)
|
||||||
|
await _apply_dwell_behaviour(page, behaviour)
|
||||||
html: str = await page.content() # type: ignore[attr-defined]
|
html: str = await page.content() # type: ignore[attr-defined]
|
||||||
|
|
||||||
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
|
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
|
||||||
|
|
@ -2140,6 +2382,14 @@ async def _fetch_once(
|
||||||
f"— отказ площадки, ждать нечего url={url!r}"
|
f"— отказ площадки, ждать нечего url={url!r}"
|
||||||
)
|
)
|
||||||
html = await _wait_out_pow_challenge(page, provider, url)
|
html = await _wait_out_pow_challenge(page, provider, url)
|
||||||
|
|
||||||
|
# hop_recommended (#3283) — ПОСЛЕ того как html снят и провалидирован (бан/
|
||||||
|
# челлендж уже исключены выше, иначе исключение долетело бы до caller'а раньше
|
||||||
|
# этой строки). Только reuse_context=True — без него контекста, из которого
|
||||||
|
# можно open'нуть побочную вкладку не задев основную page, нет. Best-effort:
|
||||||
|
# см. докстринг _hop_to_recommended_link — отказ на нём не всплывает сюда.
|
||||||
|
if behaviour is not None and behaviour.hop_recommended and reuse_context:
|
||||||
|
await _hop_to_recommended_link(page, provider, url)
|
||||||
finally:
|
finally:
|
||||||
await page.close() # type: ignore[attr-defined]
|
await page.close() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,7 @@ class _OverlapProbe:
|
||||||
reuse_context: bool = False,
|
reuse_context: bool = False,
|
||||||
reset_context: bool = False,
|
reset_context: bool = False,
|
||||||
fetch_mode: str = "navigate",
|
fetch_mode: str = "navigate",
|
||||||
|
behaviour: object = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
self.active[provider] = self.active.get(provider, 0) + 1
|
self.active[provider] = self.active.get(provider, 0) + 1
|
||||||
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])
|
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])
|
||||||
|
|
|
||||||
349
tradein-mvp/browser/test_server_behaviour.py
Normal file
349
tradein-mvp/browser/test_server_behaviour.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""test_server_behaviour.py — имитация чтения страницы человеком (#3283).
|
||||||
|
|
||||||
|
По умолчанию (body["behaviour"] отсутствует/пуст) поведение /fetch байт-в-байт то
|
||||||
|
же, что и до #3283 — вклад имитации в проходимость анти-бота пока НЕ доказан, и
|
||||||
|
прод должен уметь честно померить "с behaviour vs без". Проверяет:
|
||||||
|
|
||||||
|
1. behaviour отсутствует → ни page.mouse.wheel, ни доп. dwell-пауза, ни побочный
|
||||||
|
заход на рекомендательную ссылку НЕ вызываются;
|
||||||
|
2. behaviour.scroll=True → 3-6 вызовов page.mouse.wheel(0, 500-1400px);
|
||||||
|
3. behaviour.dwell_ms — валидный [min, max] отрабатывает; невалидный (не пара
|
||||||
|
int, min>max, отрицательные) — /fetch отвечает 400, как и невалидный
|
||||||
|
fetch_mode;
|
||||||
|
4. behaviour.hop_recommended — заходит на ОДНУ ссылку ТОГО ЖЕ хоста, отказ на
|
||||||
|
этом заходе не роняет основной ответ; ссылка с чужого хоста не берётся;
|
||||||
|
работает только при reuse_context=True.
|
||||||
|
5. body["behaviour"] не-dict → 400.
|
||||||
|
|
||||||
|
camoufox НЕ запускается — browser/context/page подделаны, в стиле
|
||||||
|
test_server_reuse_context.py.
|
||||||
|
|
||||||
|
Запуск (из tradein-mvp/browser/)::
|
||||||
|
|
||||||
|
python -m pytest test_server_behaviour.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body(response: Any) -> dict[str, Any]:
|
||||||
|
return json.loads(response.body.decode())
|
||||||
|
|
||||||
|
|
||||||
|
async def _coro(value: Any) -> Any:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fetch_request(body: dict[str, Any]) -> Any:
|
||||||
|
request = make_mocked_request("POST", "/fetch")
|
||||||
|
request.json = lambda: _coro(body) # type: ignore[method-assign]
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@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}
|
||||||
|
)
|
||||||
|
# Джиттер пейсинга (#3283) не имеет отношения к behaviour — гасим, чтобы не
|
||||||
|
# добавлять произвольный real-time sleep в тест.
|
||||||
|
monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {p: 0.0 for p in server.PROVIDERS})
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_HTML = "<html>ok<script>window.__SSR_STATE__={}</script></html>"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMouse:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.wheel_calls: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
async def wheel(self, dx: int, dy: int) -> None:
|
||||||
|
self.wheel_calls.append((dx, dy))
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLocator:
|
||||||
|
def __init__(self, hrefs: list[str]) -> None:
|
||||||
|
self._hrefs = hrefs
|
||||||
|
|
||||||
|
async def evaluate_all(self, _js: str) -> list[str]:
|
||||||
|
return self._hrefs
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
"""Поддельная page: goto/content/mouse/locator/context, минимальный контракт _fetch_once."""
|
||||||
|
|
||||||
|
def __init__(self, context: "_FakeContext", links: list[str] | None = None) -> None:
|
||||||
|
self.context = context
|
||||||
|
self.mouse = _FakeMouse()
|
||||||
|
self.goto_urls: list[str] = []
|
||||||
|
self.wait_for_timeout_calls: list[int] = []
|
||||||
|
self.closed = 0
|
||||||
|
self._links = links or []
|
||||||
|
|
||||||
|
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_urls.append(url)
|
||||||
|
|
||||||
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
|
self.wait_for_timeout_calls.append(ms)
|
||||||
|
|
||||||
|
async def content(self) -> str:
|
||||||
|
return _DEFAULT_HTML
|
||||||
|
|
||||||
|
def locator(self, selector: str) -> _FakeLocator:
|
||||||
|
return _FakeLocator(self._links)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingHopPage(_FakePage):
|
||||||
|
"""Побочная (hop) страница, у которой goto всегда падает — для теста best-effort."""
|
||||||
|
|
||||||
|
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||||
|
raise TimeoutError("hop nav timeout (simulated)")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeContext:
|
||||||
|
def __init__(self, links: list[str] | None = None, hop_page_cls: type = _FakePage) -> None:
|
||||||
|
self.add_cookies_calls: list[list[dict]] = []
|
||||||
|
self.pages: list[_FakePage] = []
|
||||||
|
self._links = links or []
|
||||||
|
self._hop_page_cls = hop_page_cls
|
||||||
|
self.closed = 0
|
||||||
|
|
||||||
|
async def add_cookies(self, cookies: list[dict]) -> None:
|
||||||
|
self.add_cookies_calls.append(cookies)
|
||||||
|
|
||||||
|
async def new_page(self) -> _FakePage:
|
||||||
|
# Первая страница — "основная" (несёт links для hop-детекции), последующие —
|
||||||
|
# побочные (hop) вкладки, открытые из _hop_to_recommended_link.
|
||||||
|
cls = _FakePage if not self.pages else self._hop_page_cls
|
||||||
|
page = cls(self, links=self._links)
|
||||||
|
self.pages.append(page)
|
||||||
|
return page
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeReuseBrowser:
|
||||||
|
def __init__(self, links: list[str] | None = None, hop_page_cls: type = _FakePage) -> None:
|
||||||
|
self.contexts_created: list[_FakeContext] = []
|
||||||
|
self._links = links or []
|
||||||
|
self._hop_page_cls = hop_page_cls
|
||||||
|
|
||||||
|
async def new_context(self) -> _FakeContext:
|
||||||
|
ctx = _FakeContext(links=self._links, hop_page_cls=self._hop_page_cls)
|
||||||
|
self.contexts_created.append(ctx)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
async def new_page(self) -> _FakePage:
|
||||||
|
# Не переиспользуемый путь (reuse_context=False) в этих тестах не участвует.
|
||||||
|
raise AssertionError("browser.new_page() не должен вызываться при reuse_context=True")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. behaviour отсутствует — ни одна новая ветка не исполняется ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_behaviour_absent_is_noop() -> None:
|
||||||
|
browser = _FakeReuseBrowser()
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
|
||||||
|
html = asyncio.run(
|
||||||
|
server._fetch_once("avito", "https://www.avito.ru/1", reuse_context=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert html == _DEFAULT_HTML
|
||||||
|
page = browser.contexts_created[0].pages[0]
|
||||||
|
assert page.mouse.wheel_calls == [], "без behaviour scroll не должен вызываться"
|
||||||
|
# Единственный wait_for_timeout — обязательный BROWSER_WAIT_MS settle, не dwell.
|
||||||
|
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||||
|
assert len(browser.contexts_created[0].pages) == 1, "hop_recommended не должен заходить"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. scroll=True — 3-6 вызовов wheel в заданном диапазоне px ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_true_triggers_wheel_calls_in_range() -> None:
|
||||||
|
browser = _FakeReuseBrowser()
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
behaviour = server._Behaviour(scroll=True, dwell_ms=None, hop_recommended=False)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server._fetch_once(
|
||||||
|
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = browser.contexts_created[0].pages[0].mouse.wheel_calls
|
||||||
|
assert server.BROWSER_SCROLL_MIN_COUNT <= len(calls) <= server.BROWSER_SCROLL_MAX_COUNT
|
||||||
|
for dx, dy in calls:
|
||||||
|
assert dx == 0
|
||||||
|
assert server.BROWSER_SCROLL_MIN_PX <= dy <= server.BROWSER_SCROLL_MAX_PX
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. dwell_ms — валидация в fetch_handler (400, как fetch_mode) ────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dwell_ms",
|
||||||
|
[
|
||||||
|
[500], # не пара
|
||||||
|
[5000, 100], # min > max
|
||||||
|
[-10, 100], # отрицательное
|
||||||
|
["a", "b"], # не int
|
||||||
|
"500,1000", # вообще не список
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_dwell_ms_returns_400(dwell_ms: Any) -> None:
|
||||||
|
request = _make_fetch_request(
|
||||||
|
{"url": "https://www.avito.ru/1", "behaviour": {"dwell_ms": dwell_ms}}
|
||||||
|
)
|
||||||
|
response = asyncio.run(server.fetch_handler(request))
|
||||||
|
assert response.status == 400
|
||||||
|
assert "dwell_ms" in _json_body(response)["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_behaviour_not_dict_returns_400() -> None:
|
||||||
|
request = _make_fetch_request({"url": "https://www.avito.ru/1", "behaviour": "scroll"})
|
||||||
|
response = asyncio.run(server.fetch_handler(request))
|
||||||
|
assert response.status == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_dwell_ms_applies_extra_wait() -> None:
|
||||||
|
browser = _FakeReuseBrowser()
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
behaviour = server._Behaviour(scroll=False, dwell_ms=(100, 100), hop_recommended=False)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server._fetch_once(
|
||||||
|
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
page = browser.contexts_created[0].pages[0]
|
||||||
|
# BROWSER_WAIT_MS settle + ровно 100мс dwell (min==max=100 — детерминировано).
|
||||||
|
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS, 100]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. hop_recommended — same-host only, best-effort, требует reuse_context ──────
|
||||||
|
|
||||||
|
|
||||||
|
def test_hop_recommended_visits_same_host_link_only() -> None:
|
||||||
|
links = [
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_505_m_1216_et._8285475966", # == url, должен быть исключён
|
||||||
|
"https://sub.other.ru/kvartiry/foreign_1234567", # чужой хост, исключён
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry", # рубрика, не карточка — исключена
|
||||||
|
"https://www.avito.ru/rossiya/nedvizhimost", # навигация, исключена
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_302_m_25_et._8242031370", # валидный кандидат
|
||||||
|
]
|
||||||
|
browser = _FakeReuseBrowser(links=links)
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server._fetch_once("avito", "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_505_m_1216_et._8285475966", reuse_context=True, behaviour=behaviour)
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = browser.contexts_created[0]
|
||||||
|
assert len(ctx.pages) == 2, "должна открыться ровно одна побочная (hop) страница"
|
||||||
|
hop_page = ctx.pages[1]
|
||||||
|
assert hop_page.goto_urls == ["https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_302_m_25_et._8242031370"]
|
||||||
|
assert hop_page.closed == 1, "hop-страница должна закрыться"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hop_recommended_skips_same_host_non_card_links() -> None:
|
||||||
|
"""#3283: одного host-фильтра мало — без шаблона URL хоп уводил бы на рубрику.
|
||||||
|
|
||||||
|
Шаблон Авито (путь с /kvartiry/ + id из 7+ цифр) снят на живом замере 31.08.2026.
|
||||||
|
"""
|
||||||
|
links = [
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry",
|
||||||
|
"https://www.avito.ru/ekaterinburg/kvartiry/prodam",
|
||||||
|
"https://www.avito.ru/company/about",
|
||||||
|
]
|
||||||
|
browser = _FakeReuseBrowser(links=links)
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server._fetch_once("avito", "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_505_m_1216_et._8285475966", reuse_context=True, behaviour=behaviour)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(browser.contexts_created[0].pages) == 1, "рубрика не карточка — хопа быть не должно"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hop_recommended_skips_when_only_foreign_host_links() -> None:
|
||||||
|
links = ["https://sub.other.ru/kvartiry/foreign_1234567", "https://another.tld/kvartiry/x_7654321"]
|
||||||
|
browser = _FakeReuseBrowser(links=links)
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
server._fetch_once(
|
||||||
|
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(browser.contexts_created[0].pages) == 1, "чужой хост не должен открывать hop"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hop_recommended_failure_does_not_break_main_response() -> None:
|
||||||
|
links = ["https://www.avito.ru/ekaterinburg/kvartiry/1-k._kvartira_302_m_25_et._8242031370"]
|
||||||
|
browser = _FakeReuseBrowser(links=links, hop_page_cls=_FailingHopPage)
|
||||||
|
server._browsers["avito"] = browser
|
||||||
|
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
|
||||||
|
|
||||||
|
html = asyncio.run(
|
||||||
|
server._fetch_once(
|
||||||
|
"avito", "https://www.avito.ru/1", reuse_context=True, behaviour=behaviour
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert html == _DEFAULT_HTML, "отказ на hop не должен ронять/менять основной ответ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hop_recommended_noop_without_reuse_context() -> None:
|
||||||
|
"""hop_recommended=True, но reuse_context=False — hop не выполняется (нет context)."""
|
||||||
|
server._browsers["avito"] = object()
|
||||||
|
behaviour = server._Behaviour(scroll=False, dwell_ms=None, hop_recommended=True)
|
||||||
|
|
||||||
|
# _fetch_once без reuse_context идёт через browser.new_page() — подделаем browser.
|
||||||
|
class _PlainBrowser:
|
||||||
|
async def new_page(self) -> _FakePage:
|
||||||
|
return _FakePage(context=_FakeContext())
|
||||||
|
|
||||||
|
server._browsers["avito"] = _PlainBrowser()
|
||||||
|
|
||||||
|
html = asyncio.run(
|
||||||
|
server._fetch_once("avito", "https://www.avito.ru/1", behaviour=behaviour)
|
||||||
|
)
|
||||||
|
assert html == _DEFAULT_HTML
|
||||||
Loading…
Add table
Reference in a new issue