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

У сайдкара не было ни одной строки имитации поведения: после 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:
bot-backend 2026-09-01 09:48:33 +03:00
parent 9219ab6507
commit 8bb20c05ea
3 changed files with 603 additions and 3 deletions

View file

@ -141,7 +141,10 @@ import base64
import gzip
import logging
import os
import random
import re
from collections.abc import Callable, Mapping
from typing import NamedTuple
from urllib.parse import quote, urlparse
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.
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) на
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
# ТОЛЬКО задержка перед 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")
# Ссылки для 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:
"""Парсит булев env-флаг ("true"/"1"/"yes"/"on" → True), fallback на default."""
@ -261,6 +316,18 @@ _BLOCKED_TYPES: frozenset[str] = _parse_block_types(
# прежде чем открыть новую страницу. 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 ЧЕРЕЗ реальный
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
@ -1299,6 +1366,46 @@ async def fetch_handler(request: web.Request) -> web.Response:
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)
proxy_override = _resolve_proxy_override(body, provider)
@ -1340,6 +1447,7 @@ async def fetch_handler(request: web.Request) -> web.Response:
reuse_context=reuse_context,
reset_context=reset_context,
fetch_mode=fetch_mode,
behaviour=behaviour,
)
# Читаем статус под тем же локом, что и сам фетч, — иначе следующий
# запрос этого провайдера успел бы его перезаписать.
@ -1488,10 +1596,16 @@ async def _pace_provider(provider: str) -> None:
Записывает момент ПОСЛЕ возможного sleep (т.е. фактический момент следующего goto),
чтобы интервал считался между реальными навигациями, а не между входами в функцию.
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)
if interval <= 0:
base_interval = _MIN_PAGE_INTERVAL_BY_PROVIDER.get(provider, BROWSER_MIN_PAGE_INTERVAL_S)
if base_interval <= 0:
return
interval = base_interval + random.uniform(0, BROWSER_PAGE_INTERVAL_JITTER_S)
now = asyncio.get_event_loop().time()
last = _last_goto_at.get(provider)
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()
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) ────────────────────────────────────────────────
# Сайдкар всю жизнь умел ровно одно: navigate — page.goto(url). Для HTML-страницы это
# и есть то, что делает человек. Но два нужных нам ресурса Домклика страницами НЕ
@ -1550,6 +1774,7 @@ async def _do_fetch(
reuse_context: bool = False,
reset_context: bool = False,
fetch_mode: str = "navigate",
behaviour: "_Behaviour | None" = None,
) -> str:
"""Одна попытка навигации; при краше браузера — relaunch и один retry.
@ -1561,7 +1786,9 @@ async def _do_fetch(
а не обратится к context'у уже мёртвого браузера.
origin/referer/cookies/reuse_context/reset_context см. _fetch_once. Дефолты не
меняют поведение.
меняют поведение. behaviour (#3283) — None (дефолт) → ни одна из
_apply_scroll_behaviour/_apply_dwell_behaviour/_hop_to_recommended_link не
вызывается, поведение то же, что и до #3283.
"""
try:
return await _fetch_once(
@ -1573,6 +1800,7 @@ async def _do_fetch(
reuse_context=reuse_context,
reset_context=reset_context,
fetch_mode=fetch_mode,
behaviour=behaviour,
)
except Exception as exc:
if _is_browser_crash(exc):
@ -1594,6 +1822,7 @@ async def _do_fetch(
reuse_context=reuse_context,
reset_context=reset_context,
fetch_mode=fetch_mode,
behaviour=behaviour,
)
raise
@ -1962,12 +2191,20 @@ async def _fetch_once(
reuse_context: bool = False,
reset_context: bool = False,
fetch_mode: str = "navigate",
behaviour: "_Behaviour | None" = None,
) -> str:
"""Открывает страницу, переходит по URL, ждёт JS, возвращает HTML.
Caller держит _locks[provider], поэтому страницы на этом инстансе не
параллелятся 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), на который камуфокс
заходит ПЕРЕД целевым url, чтобы получить пропуск QRATOR в контексте (зеркалит
_fetch_json_once, #1917 — DomClick card-fetch, эмпирически подтверждено вживую
@ -2085,6 +2322,11 @@ async def _fetch_once(
_last_response_status[provider] = _status_of(response)
if BROWSER_WAIT_MS > 0:
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]
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
@ -2140,6 +2382,14 @@ async def _fetch_once(
f"— отказ площадки, ждать нечего url={url!r}"
)
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:
await page.close() # type: ignore[attr-defined]

View file

@ -183,6 +183,7 @@ class _OverlapProbe:
reuse_context: bool = False,
reset_context: bool = False,
fetch_mode: str = "navigate",
behaviour: object = None,
) -> str:
self.active[provider] = self.active.get(provider, 0) + 1
self.peak[provider] = max(self.peak.get(provider, 0), self.active[provider])

View 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