Skip to main content

netkit (s-netkit)

Сетевой слой китов. Всё, чем интеграция разговаривает с чужим сервером: транспорт, темп, живучесть, деградация. Ставится и работает без оркестратора — зависимости идут строго вниз: netkit → corekit.

     clikit        adapterkit        <- ветки-оболочки
          \           /
           librarykit                <- ОРКЕСТРАТОР (сессии, браузер, склад)
               |
            netkit                   <- СЕТЬ (этот пакет)
               |
            corekit                  <- ОСНОВАНИЕ (значения и правила)

Что внутри

модуль ответственность
netkit.transport исполнители запроса поверх httpx (async + sync близнец), choke-point HttpClient/SyncHttpClient, RestHttpClient к своему backend, permissive-рецепты
netkit.inbound ВХОДЯЩИЙ event-транспорт (перенос из iokit.transport, волна 5): PollingTransport/LongPollTransport/WebhookTransport (extra [webhook])/MtprotoTransport (extra [mtproto])/WebSocketTransport (extra [ws]) — как получаем события, без знания сервиса
netkit.outfit НАРЯД: единственная дверь, где персона и род запроса превращаются в то, что нужно проводу — заголовки, цель подражания, версия протокола
netkit.outfit_guard страж этой двери: исполнитель, собравший заголовки сам, ловится проверкой, а не ревью
netkit.fingerprint из чего наряд собран: версия браузера, платформа (персона → машина), User-Agent, client hints, Sec-Fetch по режиму запроса, кодировки ответа, порядок заголовков Chrome
netkit.stream persistent-каналы: StreamTransport, WS-реализация (extra [ws]), общий на процесс контекст шифрования
netkit.hold ДЕРЖАТЕЛЬ такого канала: соединение живёт сутками — возврат после обрыва с нарастающей паузой, keepalive с ожиданием ответа, разлогин отдельной бедой, предел одновременных соединений
netkit.rpc codec-слой RPC: JsonCodec / PrefixedJsonCodec / RpcClient
netkit.graphql GraphQL-клиент поверх транспорта кита
netkit.limit RateLimiter + token-bucket: ПРОАКТИВНЫЙ темп, а не «поймал 429 — поспал»
netkit.retry политики повторов: header-driven (RetryPolicy) и фиксированная (SimpleRetryPolicy)
netkit.ladder лестница деградации: чем выполнять запросы и чем добывать состояние, память ступени, события спуска
netkit.pagination / netkit.upload / netkit.forms листание ресурса, resumable-догрузка, form-urlencoded кодек
netkit.sse разбор SSE-кадров (sync parse_frames + async aparse_frames, одна машина состояний) и серверный рендер render_sse/SSE_HEADERS — один дом вместо пяти копий по продуктам
netkit.errmap ответ сервера → доменная ошибка (декларативная таблица)
netkit.providers СЛОТЫ верхнего слоя: браузерный минт, склад состояния, диагностика, egress

Наряд: «чем мы выглядим» — в одном месте

Персона (corekit.persona.ClientPersona) описывает ОДНОГО посетителя: браузер, платформу, язык, часы, рукопожатие, выход. Превращается она в то, что нужно проводу, ровно один раз — дверью outfit_for, а исполнители получают ГОТОВОЕ:

from netkit.outfit import outfit_for

outfit = outfit_for(persona=persona, mode="page-request")
outfit.headers       # заголовки целиком, уже в браузерном ПОРЯДКЕ
outfit.http2         # версия протокола (персона о ней знает)
outfit.tls_target    # цель подражания — нужна только curl_cffi (считается лениво)

На практике этого не пишут вовсе: наряд собирают сами транспорты, choke-point'ы и permissive-рецепты — достаточно передать им persona=:

transport = HttpxTransport(persona=persona)                    # прямая ступень
transport = CurlCffiTransport(persona=persona)                 # ступень подражания
client = build_permissive_http_client(cookies=..., persona=persona)

Почему одна дверь. Пока сборка была скопирована по местам, персона доезжала до одних исполнителей и молча терялась у других — а видно это становилось не ошибкой, а отзывом сессии через несколько часов. Что дверь действительно одна, держит netkit.outfit_guard: он метит наряд на самой двери и ловит исполнителя, чей ушедший набор метки не несёт.

Прогрев на старте демонов. Версию Chrome на машине наряд узнаёт лестницей (fingerprint.installed_chrome_version): обход диска, а на Linux ещё и subprocess.run с таймаутом 5 с — это не то, чем можно синхронно занять event loop. Поэтому в async-процессе кит за версией НЕ ходит вовсе: при первом обращении под работающим loop он фиксирует «Chrome не видно» и весь процесс собирает наряд по зашитой CHROME_VERSION — ровно как на машине без Chrome.

Это осознанно. Единственная альтернатива ленивому детекту под loop — узнать версию позже, фоном, и подменить её в кэше; тогда первый запрос сессии уходит с зашитой версией, а следующий — с настоящей, то есть «один браузер» меняет версию посреди сессии. Для сервиса это улика подделки, и она дороже, чем отставшая на релиз цифра. Постоянство отпечатка важнее его точности.

Чтобы демон работал с настоящей версией, прогрей кэш ЯВНО — до первой сборки клиента:

import netkit

await netkit.warm_chrome_version()

Прогрев идемпотентен (параллельные вызовы сходятся к одному детекту) и перезаписывает кэш, даже если ленивый путь уже зафиксировал там пусто: явный вызов — воля вызывающего. Но звать его надо ДО первого запроса: после первого ленивого обращения версия в процессе уже зафиксирована, и поздний прогрев сменит отпечаток посреди сессии — то самое, чего мы избегаем.

Браузерный путь: та же дверь, форма Playwright

HTTP- и браузерный путь заявляют себя ОДНОЙ формулой, но разной формой: HTTP шлёт User-Agent заголовком, браузерный движок ставит его сам из kwargs контекста. browser_context_kwargs — парная browser_headers дверь именно для этой формы:

from netkit.fingerprint import browser_context_kwargs

kwargs = browser_context_kwargs(persona=persona)
kwargs["user_agent"]         # тот же, что browser_headers(...)["User-Agent"]
kwargs["extra_http_headers"] # client hints БЕЗ User-Agent — его ставит движок
kwargs["locale"]             # и timezone_id/viewport/screen — если персона их знает

У собранного наряда — та же дверь методом:

outfit.browser_context()  # == browser_context_kwargs(outfit.browser_version, persona=outfit.persona)

Пока каждый потребитель собирал это из кусков, client hints в браузерный контекст не доезжали нигде — ровно та болезнь, от которой лечит наряд, только на браузерной стороне.

Кто главнее: вызывающий или умолчание кита

Вызывающий — поимённо. Кит говорит за него только там, где тот промолчал. Правило одно на весь кит (fingerprint.merged_headers) и действует на всех путях: прямая ступень, ступень подражания, per-call мердж choke-point'а и permissive-рецепты.

client = build_permissive_http_client(
    follow_redirects=True,
    headers={"accept-language": "de-DE"},   # уедет на провод именно de-DE
)

Назвавший свой User-Agent забирает ВСЁ заявление о себе — и UA, и client hints. Смешать чужой User-Agent с нашими sec-ch-ua значит собрать клиента, который в одном заголовке одна программа, а в другом Chrome: это ловится одним сравнением и хуже честного не-браузера.

Почему об этом отдельный раздел. У рецепта ДВА слоя набора — заголовки httpx-клиента и заголовки choke-point'а, — и до 0.0.18 они соревновались молча: названный язык принимался фабрикой и терялся на проводе, потому что per-request у httpx заменяет клиентский одноимённый. Хуже всего было именно это состояние: заголовок принят без возражений и потерян по дороге. Теперь оба слоя сводятся в один (transport.named_by_the_caller), а base_headers как слой, названный ближе к запросу, старше headers.

Платформа: персона → машина → названная константа

Персоны нет — платформа не берётся из константы, а выводится по машине (как и версия браузера). До этого она была зашита словом «Windows», и на Linux-ноде клиент заявлял Windows, показывая настоящую машину всем остальным.

from netkit.fingerprint import declared_platform

declared_platform().describe()          # 'Linux — x86/64 (откуда: машина)'
declared_platform(persona).describe()   # 'macOS 14.5 arm/64 (откуда: персона)'

Персона, называющая macOS с Linux-ноды, не врёт: заявленная платформа — это устройство аккаунта, а не нода, на которой крутится процесс, и узнавание устройства держится именно за неё. Лечится другое — платформа, которую никто не выбирал; поэтому у неё есть origin, и его печатают наряд (Outfit.platform) и проба (python -m netkit.fingerprint_probe). Написание платформы, её кусок в User-Agent и версия лежат ОДНОЙ строкой таблицы PLATFORMS — новая платформа добавляется строкой, а не ветвлением.

Кодировки: заявляем ровно то, что разожмём

Chrome шлёт Accept-Encoding: gzip, deflate, br, zstd — и кит шлёт то же самое. Списать эту строку дословно было нельзя: сервер верит и присылает br, а клиент без декодера пропускает сжатое тело наверх молча, и падает потом разбор JSON где-то в навыке. Поэтому строка собирается из двух фактов — что заявляет браузер и что умеет распаковать ТОТ, КТО понесёт запрос:

from netkit.fingerprint import DECODER_CURL, accept_encoding

accept_encoding()               # 'gzip, deflate, br, zstd' — распакует httpx
accept_encoding(DECODER_CURL)   # то же, но умения спрошены у сборки curl

Декодеры (brotli, zstandard) приезжают ОСНОВНОЙ зависимостью — через extra самого httpx, чтобы вилки версий объявлял он, а не мы. В окружении, где их всё-таки нет (--no-deps, замороженный requirements), набор сужается сам — заявляем меньше, а не врём больше. У ступени подражания распаковщик свой (кодеки, вкомпилированные в curl-impersonate), поэтому её строка не зависит от питоновских вендоров вовсе — и наоборот.

Держать соединение сутками, а не один обмен

netkit.stream умеет «открыть — послать — принять — закрыть». Каналу, который обязан быть на связи круглосуточно (входящее приходит пушем, курсора в протоколе нет), этого мало: кто-то должен держать сокет открытым и возвращаться после обрыва. Это netkit.hold.

from netkit.hold import ConnectionBudget, HoldPolicy, StreamHolder

holder = StreamHolder(
    connect=lambda: MyServiceConnection(session),   # порт `HeldConnection` — КАНАЛА
    on_frame=inbox.put,                            # СТОЙКАЯ очередь, не буфер в памяти
    policy=HoldPolicy(ping_every=30.0, ping_timeout=30.0),
    on_state=lambda state: log.info("канал: %s", state),
    on_revoked=notify_owner,                       # сессия мертва — нужен новый вход
    budget=ConnectionBudget(200),                  # сколько сокетов держим сразу
)
await holder.run()                                 # держит, пока не остановят
ответ = await holder.call({"op": "история"})       # запрос поверх ЖИВОГО соединения

Что делает держатель и почему именно так (каждое — из замера, а не из вкуса):

  • возвращается после обрыва с нарастающей паузой, и сбрасывает лестницу после удачного входа: сутки работы и обрыв — это не «десятая неудачная попытка подряд». Пауза не вежливость: бурст соединений сервер режет на уровне TLS примерно на минуту, и при массовом обрыве десятки держателей не должны ломиться в такт;
  • пингует и ЖДЁТ ответ, считая срок от ПЕРВОГО неотвеченного пинга. Считая от последнего, «сервер молчит» не наступало бы никогда: каждый следующий пинг сдвигал бы срок вперёд, и держатель бодро пинговал бы мёртвую сеть. Пассивное чтение об обрыве не узнаёт вовсе — мёртвая сеть выглядит как тишина;
  • различает «оборвалось» и «нас разлогинили»: первое лечится возвратом (ConnectionLostError), второе не лечится ничем (SessionRevokedError) и обязано выйти наружу сигналом. Молчащий держатель по мёртвому токену выглядит рабочим, и сообщения теряются «без ошибок»;
  • читает сокет ОДИН: call не читает сам, а ждёт кадр со своим номером из общего цикла. Два независимых читателя растащили бы кадры между собой;
  • не буферизует: пришедшее уходит в on_frame, и лишь ПОСЛЕ этого канал подтверждает доставку серверу. Обратный порядок однажды означал бы «серверу сказали, что доставлено» при потерянном сообщении.

Кит не знает ни одного сервиса. Адрес, формат кадра, опкоды, порядок входа и код разлогина приносит КАНАЛ, реализуя узкий порт HeldConnection (открыть, войти, принять кадр, пингануть, опознать кадр FrameVerdict, закрыть). Держатель отвечает только за ВРЕМЯ.

Не всякий канал имеет смысл держать. PeriodicHolder — тот же движок, но заходами по расписанию: подключился, вычитал накопленное до тишины, отключился. Дешевле по памяти, дороже по задержке.

Сколько аккаунтов влезет на машину — вопрос не темпа, а числа открытых сокетов, и отвечает на него ConnectionBudget (netkit.limit считает обмены в единицу времени и этого не закрывает). Второй множитель — память: WS-транспорт по умолчанию берёт shared_ssl_context(), ОДИН контекст шифрования на процесс. Замер: вендор, создающий свой контекст на каждое соединение, стоит 2,18 МБ на сессию против 0,38–0,42 МБ с общим — впятеро, то есть 1,1 ГБ против 0,2 ГБ на 500 сессий.

Шкала ступеней: по стоимости, а не по «продвинутости»

  1. прямой запрос (httpx);
  2. прямой запрос с имитацией браузера (curl_cffi: подделка рукопожатия) — цель работы в том, чтобы оставаться на этих двух: ни окна, ни процесса браузера здесь нет;
  3. настоящий браузер БЕЗ окна (headless): основной движок nodriver, camoufox — запасной НА ТОЙ ЖЕ ступени, когда nodriver палится;
  4. настоящий браузер С ВИДИМЫМ ОКНОМ — только вход человека; лестница сюда не спускается вовсе.

Третья и четвёртая различаются ВИДИМОСТЬЮ ОКНА, а не стелсом. Шкала объявлена данными (netkit.ladder.LADDER_RUNGS), поэтому новая возможность обязана сказать, что при ней происходит и сколько это стоит.

Слоты: как netkit зовёт то, что живёт выше

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

from netkit.providers import SLOT_BROWSER_MINT, register_provider
register_provider(SLOT_BROWSER_MINT, my_mint_session)

Слоты со своим дефолтом (json_store, file_lock, state_root, path_slug, transport_factory, diagnose, egress_proxy) никогда не роняют вызов — netkit умеет их сам, верхний слой лишь уточняет. Слоты без дефолта (браузерные) при обращении поднимают ProviderMissing с инструкцией: молчаливой деградации «ступень тихо ничего не сделала» здесь нет.

Если в окружении стоит librarykit, его импорт заполняет все слоты сам — отдельная регистрация не нужна.

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

librarykit остаётся фасадом: librarykit.transport, librarykit.ladder, librarykit.limit, librarykit.retry, librarykit.pagination, librarykit.upload, librarykit.forms, librarykit.rpc, librarykit.stream, librarykit.graphql, librarykit.errmap реэкспортируют ТЕ ЖЕ объекты (не копии) — isinstance / except / is работают через любой из путей.

Стоимость импорта

import netkit не исполняет ни одного подмодуля: ни httpx, ни stamina, ни asyncio. Имена резолвятся по PEP 562 при первом обращении — платит тот, кому нужно.

Установка

pip install s-netkit          # ядро: corekit + httpx + stamina
pip install s-netkit[ws]      # + websockets для WS-транспорта

Metadata

Release files for s-netkit 0.1.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for s-netkit 0.1.5
File Size Uploaded
s_netkit-0.1.5.tar.gz 473.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for s-netkit 0.1.5
File Interpreter ABI Platform
s_netkit-0.1.5-py3-none-any.whl Python 3 none any Details

Total release size: 761.7 kB

Release files / s_netkit-0.1.5.tar.gz

Download URL s_netkit-0.1.5.tar.gz
Size 473.7 kB
Tags Source
SHA-256 checksum
How to use checksums
7fa6541736af1349e49f423fb6849a439a789d3fe6af4ba9e0ed4fc0fbfd192a
BLAKE2b-256 checksum
How to use checksums
e3cbb0892b3d1158c552d7508371b8459e74baad743eb9c2c275adf6b42b4e17
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / s_netkit-0.1.5-py3-none-any.whl

Download URL s_netkit-0.1.5-py3-none-any.whl
Size 287.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7c839dc475ceb7982396861626757df2c2c8c97ed83142fe6184211ae96d011f
BLAKE2b-256 checksum
How to use checksums
9e864b76b95d7f936fb448667d5b6e1f2eab79728c7328ed818be29b4ddf2d43
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.5 This release

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.21

2 release files

0.0.20

2 release files

0.0.19

2 release files

0.0.18

2 release files

0.0.17

2 release files

0.0.16

2 release files

0.0.15

2 release files

0.0.11

2 release files

0.0.10

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page