Metadata-Version: 2.5
Name: s-browserkit
Version: 0.0.20
Summary: Браузерный слой для Python-автоматизации: стелс-сессии, антибот-запросы и capture трафика через единый фасад возможностей, а не имя движка.
Author: Dmitry
License: MIT
License-File: LICENSE
Keywords: antibot,automation,browser,cdp,http,playwright,stealth
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: s-corekit>=0.0.11
Provides-Extra: antibot
Requires-Dist: curl-cffi>=0.7; extra == 'antibot'
Requires-Dist: s-netkit>=0.0.16; extra == 'antibot'
Provides-Extra: browser
Requires-Dist: playwright>=1.40; extra == 'browser'
Provides-Extra: camoufox
Requires-Dist: camoufox>=0.5.4; extra == 'camoufox'
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Provides-Extra: nodriver
Requires-Dist: nodriver>=0.50; extra == 'nodriver'
Provides-Extra: patchright
Requires-Dist: patchright>=1.49; extra == 'patchright'
Description-Content-Type: text/markdown

# s-browserkit — браузерный слой китов

Живой браузер, антибот и стелс — одним китом, который ставится **отдельно**.
Чистой API-интеграции (белый REST/GraphQL) он не нужен и больше не навязывается:
`librarykit` без `browserkit` полностью работоспособен.

```
        clikit          adapterkit          ветки-оболочки
             \             /
              \           /
               librarykit                   ОРКЕСТРАТОР (сессии, лимиты, фабрики)
                /        \
        browserkit        …                 СЛОЙ-КИТ (браузер и антибот)
                \        /
                 corekit                    ОСНОВАНИЕ (значения и чистые правила)
```

## Главное: просят ВОЗМОЖНОСТЬ, а не движок

```python
import browserkit

backend = browserkit.open_browser(
    browserkit.Capability.STEALTH_SESSION,   # ЦЕЛЬ, а не «camoufox»
    profile_dir=profile,
)
http = browserkit.make_requester(browserkit.Capability.STEALTH_REQUEST)
```

Соответствие «цель → движок» живёт ровно в одной таблице внутри кита
(`browserkit/capabilities.py`). Anti-detect движок начал палиться — правится одна
строка, **ни один навык не меняется**, потому что ни один навык движок не назвал.

Возможности:

| Capability | Что нужно потребителю |
|---|---|
| `STEALTH_SESSION` | поднять браузер там, где обычный палится antibot'ом |
| `VISIBLE_LOGIN`   | видимое окно: пароль/2FA человек вводит руками |
| `PERSISTENT_WORK` | обычная автоматизация в персистентном профиле |
| `STEALTH_REQUEST` | один HTTP-запрос с браузерным TLS-отпечатком, БЕЗ браузера |
| `IN_PAGE_REQUEST` | запрос изнутри живой страницы (её cookies/TLS/заголовки) |
| `TRAFFIC_CAPTURE` | нормализованный сетевой трафик страницы (реверс скрытого API) |

Диагностика — «чем закрыта каждая цель прямо сейчас»:

Сразу после `pip install s-browserkit` (без единого extra) все цели пусты — это и
есть нормальный ответ, а не поломка:

```python
>>> import browserkit
>>> browserkit.describe_capabilities()
{'stealth_session': None, 'visible_login': None, 'persistent_work': None,
 'stealth_request': None, 'in_page_request': None, 'traffic_capture': None}
```

`None` означает «не установлен ни один исполнитель». Попытка воспользоваться такой
целью даёт внятную ошибку с рецептом установки, а не голый `ModuleNotFoundError`:

```python
>>> browserkit.make_requester(browserkit.Capability.STEALTH_REQUEST)
Traceback (most recent call last):
  ...
browserkit.CapabilityUnavailable: Возможность 'stealth_request' недоступна:
ни один исполнитель не установлен. Поставь один из: `pip install 's-browserkit[antibot]'`.
```

После `pip install 's-browserkit[antibot]'` та же строка вернёт готовый
исполнитель (`CurlCffiTransport`), а `describe_capabilities()['stealth_request']`
станет `'tls_impersonate'` — имя ИСПОЛНИТЕЛЯ цели, не пакета. Какие исполнители
стоят в очереди за каждой целью, видно заранее:

```python
>>> browserkit.capability_plan(browserkit.Capability.STEALTH_SESSION)
('camoufox', 'nodriver', 'cdp')
```

Побеждает первый установленный; порядок переопределяется `set_capability_plan()`.

## Состав

* `browserkit.browser` — лаунчеры (системный Chromium по CDP, персистентный
  контекст), anti-detect / undetected бэкенды, `capture_tap` (нормализованный
  event-tap над любым движком), `state` (storage-state, тёплый вход, детекторы
  протухания), `egress` (выход сессии и пробы «чем нас видят снаружи»),
  `persona` (часы, язык, экран и UA сессии — одним заявлением на все движки),
  `webauthn` (**passkey без человека**: виртуальный аутентификатор CDP, а ключ
  живёт СНАРУЖИ профиля браузера — см. ниже), геометрия окна, брони портов;
* `browserkit.antibot` — исполнители HTTP-запроса, ходящие «как браузер», минтер
  токенов тёплой страницей и `token_cache` — кэш чеканных токенов: срок жизни и
  одноразовость учитываются ПОРОЗНЬ, а одновременные чеканки схлопываются в одну
  (вместо N подъёмов браузера); `bundle_daemon` — постоянный процесс, исполняющий
  вендорский JS-бандл БЕЗ окна браузера (Tier 3: подъём один раз, вызов —
  десятки миллисекунд). Node — опциональная зависимость, нет его → внятный отказ
  с рецептом; `mint_ladder` + `mint_gate` — **лестница чеканки**: один вызов «дай
  токен» со спуском от дешёвой ступени к дорогой (см. ниже);
* `browserkit.profile_health` — предстартовая проверка профиля и карантин;
* `browserkit.proc` — запуск подпроцессов без всплывающих консолей;
* `browserkit.ports` — Protocol'ы, которых киту не хватает снаружи;
* `browserkit.browser_guard` + `browserkit.testing` — **запрет браузера в рабочем
  пути** (см. ниже).

## Персона: чем сессия выглядит

Детектор 2026 года ищет не «плохой отпечаток», а ПРОТИВОРЕЧИЕ МЕЖДУ СЛОЯМИ.
Самое дешёвое для него — часы против адреса: страну называет прокси, а часовой
пояс отдаёт сам браузер. Поэтому персона (`corekit.persona.ClientPersona`) едет
в запуск ОДНИМ заявлением и доезжает до всех четырёх путей:

```python
backend = browserkit.browser.build_capture_backend(
    "nodriver", profile_dir=profile, proxy="socks5://fi.example:1080",
    persona=persona,          # часы, язык, экран, окно, UA — одним значением
)
```

Три правила, которые из этого следуют:

* **несогласованная персона не стартует.** Часы Москвы при финском выходе — это
  `PersonaInconsistent` в конструкторе бэкенда, ДО спавна процесса. Мы отказываем,
  а не чиним: «починить» значит решить за вызывающего, какой из слоёв врёт;
* **умолчание названо.** Персону не передали — кит строит её из того, что знает о
  машине, и печатает перед стартом (`[browserkit.persona] …`). Часы уезжают
  явным `timezone_id` даже когда совпадают с системными: явное можно сверить с
  адресом, унаследованное молча — нельзя;
* **заявление проверяется наблюдением.** `confirm_persona_egress(backend, plan)`
  спрашивает у живой сессии её настоящую страну и бьёт `SessionEgressMismatch`,
  если она разошлась с заявленной.

## Passkey без человека — и ключ НЕ в профиле браузера

Для защиты входа `WebAuthn` + passkey сильнее любой CAPTCHA, и сервисы уводят
туда чувствительные аккаунты. До сих пор наш ответ на passkey был один — «дальше
только вручную». Теперь есть виртуальный аутентификатор через CDP-домен
`WebAuthn` — тот самый механизм, которым разработчики сервисов тестируют свои же
passkey-формы:

```python
from browserkit.browser import SecretStorePasskeys, passkey_session

async with passkey_session(page, account="google:user@example.com",
                           store=SecretStorePasskeys(secret_store)) as auth:
    await page.goto("https://example.com/login")
    ...                        # форма passkey отрабатывает сама, без касания
    assert auth.asserted       # сервис ДЕЙСТВИТЕЛЬНО проверил ключ подписью
```

* **ключ хранится СНАРУЖИ профиля.** Профиль — кэш: мы его чистим (карантин
  `profile_health`) и пересоздаём. Ключ passkey — единственный предъявитель
  аккаунта, и класть его в то, что мы сами стираем, значит однажды стереть
  доступ. Кит ОТДАЁТ ключ значением (`PasskeyCredential`), а хранит его тот, у
  кого стор: контракт — структурный `PasskeyStorePort`, переходник
  `SecretStorePasskeys` подходит к любому стору формы `authkit_client.SecretStore`
  утино, без единого импорта и без новой зависимости у кита;
* **аутентификатор привязан к аккаунту, а не к браузеру.** Чужой ключ в него не
  заедет (`PasskeyAccountMismatch`), а персона-умолчание по машине ключом
  привязки быть не может — она одинакова у всех профилей;
* **честная граница.** Это не обход WebAuthn, а его штатное исполнение: работает
  там, где сервис принимает platform-аутентификатор, и НЕ работает там, где
  требуется аттестация настоящего устройства (enterprise, TPM/Apple/Yubico,
  device-bound). Первичный вход тоже не отменяется: чтобы passkey
  зарегистрировать, надо уже быть внутри аккаунта.

## Лестница чеканки: браузер — последняя ступень, а не первая

Токен, который сервис выдаёт только браузеру, можно добыть четырьмя способами
разной цены. Лестница делает выбор между ними СВОЙСТВОМ РАНТАЙМА, а не решением,
принятым однажды при написании навыка:

**прямой минт → прямой с имитацией браузера → настоящий браузер БЕЗ окна →
настоящий браузер С ВИДИМЫМ ОКНОМ.**

```python
from browserkit.antibot import MintLadder, MintRung, MintSpec, MintedToken, TokenCache

async def by_bundle(rung):            # дёшево: постоянный процесс, окна нет
    return MintedToken(value=await daemon.call("mint"), minter_ttl_s=120)

async def by_warm_page(rung):         # дорого: настоящий браузер без окна
    return MintedToken(value=await minter.mint(...), minter_ttl_s=120)

ladder = MintLadder(
    MintSpec(service="google-flow", kind="recaptcha-action", account="dmitry",
             minters={MintRung.DIRECT: by_bundle,
                      MintRung.HEADLESS_BROWSER: by_warm_page}),
    cache=TokenCache(),
)
token = await ladder.mint()           # спустится, ТОЛЬКО если дёшево не вышло
ladder.note_rejected("403 unusual activity")   # сервис отверг наш токен — улика
```

Четыре правила, которые из этого следуют:

* **спуск только по ПРЯМОЙ улике.** Бандл отработал и отказал, сервис не
  пропустил, сервис отверг токен — двигают ступень. Умер Node, моргнула сеть,
  кончилась память, протухла сессия — НЕТ: это объясняет симптом дешевле, чем
  усиление защиты. Граница лежит данными — `mint_gate.DENY_REASONS`;
* **причина спуска записана.** Кто, откуда, куда, почему и когда — значением
  (`MintDescent`) в журнале лестницы, в памяти ступени и предупреждением в логе;
* **возврат наверх обязателен.** По `sticky_ttl_s` запись протухает и дешёвая
  ступень пробуется заново, а `probe_up_after` раз в N успехов отправляет один
  вызов ступенью выше. Иначе одна неудача сделала бы браузер постоянным;
* **видимое окно — не деградация, а прерывание работы человека.** Лестница туда
  не спускается сама: нужно явное `mint(allow_visible=True)` И разрешение стража
  (см. следующий раздел). Браузерные ступени вообще спрашивают `browser_guard`, а
  не пролезают мимо него.

Кэш токенов стоит ПОВЕРХ лестницы: годный токен отдаётся до первого шага вниз, а
восемь одновременных вызовов дают один подъём браузера, а не восемь.

## Запрет браузера в рабочем пути

Канон транспорта: браузер поднимается только чтобы сминтить сессию, работа идёт
прямыми запросами. Кит делает это **конструкцией** — перехватом на собственных
дверях запуска. Подключение к прогону навыка — одна строка:

```python
# tests/conftest.py навыка
from browserkit.testing import forbid_real_browser  # noqa: F401
```

Страж живёт здесь, а не в оркестраторе, ровно потому, что двери — здесь: страж и
двери едут в одном дистрибутиве, поэтому «импорт прошёл» = «двери на месте». Он
обязан честно сказать, если стеречь не может: пропажа СВОЕЙ двери валит
установку (`BrowserGuardBlind`), а «пакета нет» отделено от «пакет есть, двери
нет» (`guard.absent` / `guard.blind` / `guard.describe()`).

Подробности, боевая установка и границы применимости — `docs/BROWSER_GUARD.md`.

## Откат со стелс-движка на обычный ГОВОРИТСЯ ВСЛУХ

Стелс-путь идёт `patchright`-форком (снятые палевные CDP-следы). Форка нет — кит
берёт обычный `playwright`: падать вместо работы хуже, и есть законный случай,
когда обычный движок поставлен НАМЕРЕННО. Но подмена больше не молчит — молчала
она до задачи #2061, и стоило это так: браузер поднимался, тесты зеленели,
рецепт установки формально был выполнен, а стелса не было ни в одной сессии
(замер 19.08.2026: при установленном `s-browserkit[browser]` было
`playwright True, patchright False`).

```python
import browserkit

browserkit.patchright_available()          # стоит ли ИМЕННО стелс-форк
browserkit.resolve_playwright_vendor(True) # чем поедет ФАКТИЧЕСКИ: "patchright" | "playwright"

backend = browserkit.open_browser(browserkit.Capability.TRAFFIC_CAPTURE, profile_dir=p)
backend.stealth_driver                     # ★ машинный ответ: стелс ли движок ПРЯМО СЕЙЧАС
backend.active_driver                      # он же строкой — для лога и выбора ступени
```

* **вслух** — одна строка в stderr на процесс (не на вызов), в манере
  `announce_persona`: `[browserkit.engine] стелс-движок ... не установлен —
  работаем обычным ...` с рецептом. `reset_engine_announcements()` — сказать заново;
* **строго** — `BROWSERKIT_STEALTH_STRICT=1` (или `strict=True` у загрузчика)
  превращает подмену в отказ `StealthEngineUnavailable` (наследник
  `BrowserUnavailable`). Для боевого контура и antibot-прогонов, где «тихо
  доехали на обычном» — негодный результат. По умолчанию строгости НЕТ.

Соседние движки (CamouFox, nodriver) подменой не занимаются и раньше: их
отсутствие ОТКАЗЫВАЕТ с рецептом. Правило закреплено на весь браузерный пакет
тестом `tests/test_stealth_fallback.py`.

## Установка

```bash
pip install s-browserkit                 # без движков: фасад, антибот-контракты, здоровье профиля
pip install 's-browserkit[browser]'      # + Playwright (CDP-attach, персистентный контекст)
pip install 's-browserkit[patchright]'   # + стелс-форк Playwright (снятые CDP-следы)
pip install 's-browserkit[camoufox]'     # + anti-detect Firefox   (затем `camoufox fetch`)
pip install 's-browserkit[nodriver]'     # + undetected Chromium
pip install 's-browserkit[antibot]'      # + TLS/JA3-импersonate без браузера
```

`import browserkit` не поднимает браузер и не импортирует ни одного движка:
подпакеты резолвятся лениво (PEP 562), вендоры — ещё позже, внутри бэкендов.

## Совместимость

Прежние пути (`librarykit.browser.*`, `librarykit.antibot.*`,
`librarykit.profile_health`, `librarykit.browser_guard`, `librarykit.testing`)
продолжают работать: там оставлены **алиасы на те же модули** (`sys.modules`), а
не копии. Поэтому `monkeypatch`, `isinstance` и `is`-сравнения ведут себя ровно
как до переноса, а второй реализации стража, которая через месяц разъедется с
первой, не существует.

## Тесты

```bash
uv run --no-sync pytest -q          # либо .venv\Scripts\python.exe -m pytest -q
```
