🔬 Как устроен WEB-прокси Telegram изнутри: пропуск, кадры и четыре режима доставки

О чём заметка
Технический разбор протокола WEB-прокси Telegram (tproxy, WEB proxy protocol v1) — того самого нового типа прокси, где трафик мессенджера едет внутри обычных запросов к сайту. Что это вообще такое и зачем — в обзорной заметке WEB-прокси Telegram: трафик внутри обычного сайта; здесь предполагается, что общая идея уже понятна, и разбирается сам контракт: как выводится пропуск, как выглядят кадры на проводе, как работает управление потоком и чем четыре режима доставки отличаются на уровне HTTP-запросов. Практическая установка — в отдельной заметке.
Откуда взяты данные
Всё ниже — разбор исходников серверной части
tproxy-serverи спецификацииPROTOCOL.mdиз того же репозитория по состоянию на 22 августа 2026 года, а не официальная документация Telegram. Проект помечен автором как доказательство работоспособности (proof-of-concept) — демонстрация, что идея работает, а не готовый продукт. Там, где спецификация и код расходятся, расхождение отмечено явно — в таких местах поведение определяет код, а не текст.
TL;DR
- Пропуск на вход (bridge capability) — это
HMAC-SHA256, где ключ — байты секрета MTProxy, а сообщение — строкаtdesktop-web-proxy-bridge-v1\nплюс имя хоста. Результат — 32 байта, в URL это 43 символа base64url. - Страница-мост — скрытая веб-страница, внутри которой работает скрипт-переносчик, — отдаётся только на точный запрос
GET /?bridge=<43 символа>. Любое отклонение — лишний параметр, дубль, процентное кодирование — возвращает обычную главную страницу сайта. - Кадр на проводе:
тип (1 байт) | номер потока (3 байта) | длина (4 байта) | данные. Типов девять; реле не отправляетPINGиBYE, так что в штатном обмене остаются семь — считаяPONG, который реле принимает, но спровоцировать не может. - Управление потоком — кредитное, по 4 МиБ в каждую сторону на поток. Реле возвращает кредит только после того, как байты реально записаны в сокет MTProxy.
- Очередь сессии разделена на три части с разными потолками, чтобы служебные кадры всегда могли встать в очередь, даже когда буфер данных забит под завязку.
- Отказ по одному потоку никогда не убивает сессию: клиент получает
CLOSEдля этого номера, остальные потоки работают дальше.
Два значения на входе и что из них получается
Пользователь вводит имя хоста и секрет MTProxy — те самые 32 шестнадцатеричных символа. Дальше клиент вычисляет производное значение, которое и уходит в сеть.
S = байты декодированного секрета, включая ведущий байт dd, если он есть
H = имя хоста в нижнем регистре, ASCII/IDNA A-label, без завершающей точки
context = UTF-8("tdesktop-web-proxy-bridge-v1\n" + H)
capability = base64url-без-выравнивания(HMAC-SHA256(ключ = S, сообщение = context))
URL = https://H/?bridge=capabilityЗдесь HMAC-SHA256 — стандартный способ получить из ключа и сообщения 32-байтовый отпечаток, который невозможно подделать, не зная ключа. Строка tdesktop-web-proxy-bridge-v1 — метка разделения контекстов: к доменному имени сервера она отношения не имеет, а гарантирует, что тот же секрет, использованный в другой задаче, даст другой результат. В спецификации отдельно оговорено, что имя метки заморожено ради совместимости и не делает протокол специфичным для Telegram Desktop.
Из этой формулы следуют три вещи.
Секрет не уходит в сеть. В запросе едет только производная. Скрипт на странице моста исходного секрета никогда не видит; в коде байты секрета обнуляются в памяти сразу после вычисления пропуска, а буфер файла профилей затирается после разбора.
Пропуск привязан к домену. Смена домена при том же секрете даёт другой пропуск, а значит, все ранее выданные пары «домен плюс секрет» становятся бесполезны на новом адресе. Заодно: один секрет, использованный на двух доменах, не связывает их через наблюдаемое в адресе значение.
Ротация секрета MTProxy автоматически ротирует пропуск. Отдельный механизм отзыва не нужен — но и выборочного отзыва в этой схеме не существует.
Проверочные векторы из спецификации (оба проверены пересчётом):
| Имя хоста | Байты секрета (hex) | Пропуск |
|---|---|---|
proxy.example.com | 000102030405060708090a0b0c0d0e0f | MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk |
proxy.example.com | dd000102030405060708090a0b0c0d0e0f | IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA |
Обратите внимание на вторую строку: ведущий байт dd — унаследованный от MTProxy признак режима случайного дополнения — входит в ключ HMAC. Поэтому обычный и dd-вариант одного и того же секрета дают разные пропуски, и это сделано намеренно. А вот префикс ee, то есть режим маскировки FakeTLS, здесь не поддерживается вообще: в архитектурном документе он запрещён прямым текстом, потому что защищённое соединение уже обеспечивает связка «браузер — веб-сервер». Шестнадцатибайтовый секрет, случайно начинающийся с ee, при этом принимается как самый обычный — проверка касается только 17-байтового варианта, который обязан начинаться с dd.
Требования к имени хоста
Проверка строгая, и её стоит знать заранее, потому что она отсекает привычные варианты: имя обязано быть длиной до 253 символов, содержать хотя бы одну точку (однословные имена вроде localhost отвергаются), не быть IP-адресом, состоять только из строчных ASCII-символов, цифр и дефисов, не иметь завершающей точки. Интернационализированные домены нужно записывать в ASCII-форме xn--….
Причина последнего требования не косметическая. Telegram Desktop собран на графической библиотеке Qt, и её разные версии по-разному приводят к каноническому виду отдельные символы — немецкую ß, греческую ς, невидимые соединители. Домен, набранный вручную такими символами, на Windows и на Linux может дать разные пропуски, и подключение молча не состоится.
Как сервер решает, кому отдать мост
Реле — единственный обработчик всех запросов: веб-сервер перед ним проксирует каждый путь без исключений, никакой отдельной раздачи статики нет.
Мост отдаётся только при выполнении всех условий сразу: метод строго GET, путь строго /, в строке запроса ровно один параметр с одним значением, его имя bridge, длина значения ровно 43 символа, а сама строка запроса побайтно равна bridge=<значение>. Дальше значение декодируется как base64url без выравнивания, обязано дать ровно 32 байта, и обратное кодирование обязано совпасть с исходным текстом.
Проще говоря: подойдёт ровно один вид адреса. Лишний параметр, продублированный bridge, один символ, записанный процентной последовательностью, — любое отклонение возвращает обычную главную страницу с кодом 200.
Сравнение пропуска с настроенными профилями идёт в постоянное время: цикл проходит по всем профилям до конца, без досрочного выхода при совпадении. Более того, если параметра bridge в запросе не было вовсе, сервер всё равно прогоняет то же сравнение по нулевому «манекену» — чтобы по времени ответа нельзя было отличить запрос с пропуском от запроса без него.
Единообразие ответов как отдельная инженерная задача
Любой неаутентифицированный запрос — неизвестный токен, неверный метод, битые заголовки — получает ровно тот же ответ, что и запрос несуществующей страницы: тот же код, тот же набор заголовков, то же тело. На публичной поверхности вообще нет ответов вида «метод не разрешён» или «не авторизован».
В проекте это закреплено отдельным тестом, который перебирает декартово произведение из пяти методов, шести вариантов оформления запроса (среди них — с заголовком источника, с кукой, с попыткой перейти на веб-сокет и с корректно оформленным токеном) и всех транспортных путей, и требует побайтового совпадения кода, набора заголовков и тела с эталонным промахом по несуществующему адресу.
Отдельная деталь, которую легко упустить: правило кэширования тоже единое. Любой ответ с кодом 4xx и 5xx, как и любой запрос с непустой строкой параметров, получает Cache-Control: no-store, всё остальное — public, max-age=300. Это закрывает утечку, при которой страница с пропуском не кэшировалась бы, а обычная кэшировалась.
Даже исчерпание внутренних лимитов замаскировано: если сервер не смог выдать одноразовый стартовый токен из-за ограничения скорости, он отдаёт обычную главную страницу, а не ошибку.
Стартовый токен и создание сессии
Вместе со страницей моста выдаётся стартовый токен (bootstrap) — 32 случайных байта, срок жизни две минуты. Он встраивается прямо в HTML и нужен ровно для одной операции: обменять его на сессию.
POST /api/v1/session
Authorization: Bearer <стартовый токен>
Content-Type: application/octet-stream
Тело: ровно один кадр HELLO
200 OK
X-Session-Token: <токен сессии>
X-Carrier-Mode: https | https-lanes | websocket | websocket-lanes
X-Down-Cursor: 0
Тело: ровно один кадр WELCOMEОбмен атомарный и идемпотентный. Идемпотентность реализована через отпечаток тела: у использованного токена сохраняется SHA-256 от того, что прислали в первый раз, и повтор с тем же телом вернёт тот же токен сессии. Повтор с другим телом — отказ. Это позволяет безопасно повторять создание сессии, когда ответ потерялся в сети.
Тонкость, которая объясняет одно проектное решение: стартовый токен не привязывается к IP-адресу. Браузер может загрузить страницу через один выходной адрес, а создать сессию через другой — из-за VPN, смены оператора или двойного стека IPv4/IPv6. В коде есть отдельный тест, где токен выдан с одного адреса, использован со второго и переспрошен с третьего, и всё это должно давать одну сессию.
Если после успешной аутентификации сервер упирается в лимит, он отвечает 503 с заголовком Retry-After: 1, не сжигая стартовый токен — побайтно идентичный запрос можно повторить.
Токены нигде не хранятся в открытом виде: в памяти лежат только их SHA-256. Проверка требует канонической кодировки, то есть неканонический base64 отвергается сразу.
Формат кадров
Все целые — беззнаковые, старшим байтом вперёд (порядок big-endian).
тип: 1 байт | номер потока: 3 байта | длина данных: 4 байта | данные| Код | Имя | Направление | Поток | Данные |
|---|---|---|---|---|
0x01 | OPEN | клиент → реле | ненулевой | пусто |
0x02 | DATA | обе стороны | ненулевой | непрозрачные, непустые |
0x03 | CLOSE | обе стороны | ненулевой | пусто |
0x04 | WINDOW | обе стороны | ненулевой | ненулевая 4-байтовая дельта |
0x05 | PING | реле → клиент | 0 | произвольный маркер |
0x06 | PONG | клиент → реле | 0 | точное эхо маркера |
0x10 | HELLO | клиент → реле | 0 | один байт 01 |
0x11 | WELCOME | реле → клиент | 0 | пусто |
0x1f | BYE | реле → клиент | 0 | необязательная причина |
Ключевые константы: заголовок 8 байт, максимум данных в кадре 1 МиБ, номер потока — 24 бита (до 16 777 215), в одном теле запроса не более 4096 кадров. Из сокета бэкенда реле читает кусками не больше 64 КиБ, но соседние кадры DATA одного потока склеиваются в очереди, поэтому кадр на проводе может дорасти до полного максимума в 1 МиБ — спецификация в этом месте обещает 64 КиБ и расходится с кодом.
Важное практическое замечание: PING и BYE в первой версии реле не отправляет вообще — это оговорено в спецификации и подтверждается кодом. Приёмная сторона к PONG при этом готова, и его данные ограничены 64 байтами (ограничение есть только в коде, в спецификации его нет).
Валидация формы кадров от клиента жёсткая. На нулевом потоке разрешён только PONG; всё остальное — фатальная ошибка. На ненулевом потоке: у OPEN и CLOSE данные обязаны быть пустыми, у DATA — непустыми, у WINDOW — ровно четыре байта с ненулевым значением. Пустое тело запроса тоже считается ошибкой, а не пустой операцией.
Фатальные ошибки, которые обрывают всю сессию: неизвестный номер живого потока, неизвестный тип, обрезанный кадр в конце тела, нулевая дельта WINDOW, DATA сверх выданного кредита, кадр не в том направлении. Общее правило: некорректный кадр никогда не доходит до MTProxy.
Жизненный цикл потока
Каждому TCP-соединению, которое приложение хочет открыть, соответствует один номер потока и один кадр OPEN. Номера не переиспользуются в пределах сессии.
OPEN s=17 → реле открывает одно TCP-соединение к настроенному бэкенду
DATA s=17 → ограниченная кредитом запись в этот сокет
чтение бэкенда → DATA s=17 в очередь на отдачу вниз
CLOSE s=17 → закрытие соединения
EOF бэкенда → CLOSE s=17 клиенту
неудача дозвона → CLOSE s=17, сессия продолжает работатьПодтверждения у OPEN нет: клиент может слать DATA сразу, в том же теле запроса. Пока идёт дозвон, данные копятся, но не больше начального окна.
CLOSE — это именно обрыв, а не полузакрытие: данные, стоявшие в очереди для этого потока, отбрасываются. В спецификации это объясняется тем, что штатный путь Telegram Desktop тоже никогда не делает полузакрытие на сокете MTProto.
Отдельно стоит отметить механизм памяти о закрытых потоках. Сервер помнит до 4096 недавно закрытых номеров и молча игнорирует корректно оформленные запоздалые DATA, WINDOW и CLOSE для них — иначе гонка при одновременном закрытии с двух сторон убивала бы сессию. Побочное следствие: когда номер вытесняется из этой памяти, реле уже не отличает запоздалый кадр от нового потока — поэтому запрет на переиспользование номеров лежит целиком на клиенте.
Управление потоком: кредиты и обратное давление
Каждый поток начинается с 4 МиБ кредита в каждую сторону. Единица — байты.
Механика в направлении «клиент к серверу»: клиент вправе прислать не больше, чем есть кредита. Реле возвращает кредит кадром WINDOW только после того, как байты реально ушли в сокет MTProxy. Это и есть настоящее обратное давление: медленный сервер назначения означает, что запись блокируется, кредит не возвращается, клиент упирается в свои 4 МиБ на поток и вынужден остановиться.
В обратную сторону симметрично: чтения из сокета бэкенда расходуют кредит, выданный клиентом. Когда кредит исчерпан, поток чтения внутри реле (в языке Go это горутина) паркуется и физически перестаёт читать из сокета — TCP-окно к бэкенду схлопывается само. Никакого чтения «в память про запас» нет ни на одном участке; в архитектурном документе прямо записано требование не использовать неограниченное копирование.
Тут есть асимметрия, которую стоит запомнить. Кредит, который реле возвращает в свою сторону, не может превысить начальные 4 МиБ — попытка выдать больше отвергается. А вот кредит, который выдаёт клиент, потолком в 4 МиБ не ограничен: сложение насыщающее и упирается только в максимум 32-битного числа. Само окно в 4 МиБ выбрано намеренно больше одного тела запроса (2 МиБ), чтобы поток мог продолжать передачу, пока возвращаемый кредит едет во встречном направлении.
Три раздела очереди: почему служебные кадры не делят буфер с данными
Очередь сессии на отдачу вниз разделена на три класса с разными потолками, и это решает конкретную проблему: если бы служебные кадры делили общий буфер с данными, то забитая под завязку очередь данных не дала бы отправить даже CLOSE, и пришлось бы рвать сессию.
| Класс | Потолок при настройках по умолчанию |
|---|---|
Служебные кадры (CLOSE, WINDOW) | 32 МиБ, 16 384 элемента |
| Приём тела запроса | 31,9 МиБ, 15 984 элемента |
Данные вниз (DATA) | 28,9 МиБ, 11 888 элементов |
Резерв под служебные кадры считается как 16 + число_потоков_на_сессию × 3 элементов, при стандартных 128 потоках это 400 элементов, то есть около 105 КиБ по внутреннему учёту. Второй резерв гарантирует, что целое тело запроса на 2 МиБ всегда найдёт куда встать.
Дополнительно есть предохранитель на старте: если резерв под служебные кадры, помноженный на максимальное число сессий, съедает весь глобальный буфер, процесс отказывается запускаться с внятным сообщением. Логика в комментарии кода честная: иначе это была бы тихая мина, которая рванёт под нагрузкой.
Учёт ведётся и в байтах, и в штуках, причём каждый элемент очереди дополнительно «стоит» 256 байт накладных расходов — консервативная оценка реальной стоимости выделения памяти.
Что происходит при упоре в лимиты
Реакция зависит от того, где именно упёрлись:
| Где упёрлись | Реакция |
|---|---|
| Тело запроса не помещается в бюджет | 503 с Retry-After: 1, номер последовательности не подтверждён, ни один кадр не применён, сессия жива |
| Данные вниз не помещаются | Чтение из бэкенда паркуется; если совсем никак — закрывается только этот поток |
OPEN сверх лимита потоков | CLOSE только для этого номера, сессия и остальные потоки живы |
| Неудача дозвона до бэкенда | CLOSE этого потока, сессия жива |
| Нарушение протокола | Сессия закрывается целиком |
Отдельно проверено тестами, что отказ по лимиту профиля не расходует глобальную ёмкость и что упор в потолок потоков не рвёт родительскую сессию.
Четыре режима доставки
Режим задаётся на сервере, в профиле, и вшивается в сгенерированную страницу моста. Клиенту не нужно ни настроек, ни нового кода: он получает от сервера значение в заголовке X-Carrier-Mode и сверяет его с тем, что вшито в страницу.
Создание сессии во всех четырёх режимах одинаковое — обычным запросом по HTTPS. Дальше пути расходятся.
https — базовый и самый консервативный. Один запрос на отправку и один долгий ожидающий запрос на приём, оба сериализованы. Потолок в каждую сторону — размер тела, делённый на время оборота, то есть около 2 МиБ на RTT.
https-lanes — те же два эндпоинта, но по независимой паре запросов на каждый логический поток; поток указывается заголовком X-Lane-ID. Каждая полоса имеет собственные счётчики и собственное состояние повторов, поэтому две сессии Telegram могут обе идти с первого номера и не мешать друг другу. Режим убирает взаимную блокировку между логическими соединениями, но предполагает нормально работающий HTTP/2: на старом HTTP/1.1 браузер ограничен шестью соединениями к одному адресу, и множество одновременных ожидающих запросов туда просто не поместится.
websocket — один постоянный двусторонний канал на всё. Убирает цикл «запрос — ответ» и его потолок по задержке, но все потоки делят одно соединение со всеми последствиями: общая очередь, общий контроль перегрузки, общая точка отказа. Обрыв канала закрывает сессию и все потоки, и возобновления частично доставленной сессии в эталонной реализации нет.
websocket-lanes — отдельный канал на каждый поток, номер потока указывается прямо в имени подпротокола. Изолирует очереди: загрузка большого файла не подвешивает интерактивную переписку. Плата — больше соединений и рукопожатий. Изоляция контроля перегрузки при этом не гарантирована: если движок браузера сложит несколько каналов в одно HTTP/2-соединение, они снова окажутся в общей очереди.
Разница в реакции на ошибку между двумя полосными режимами существенна и легко упускается: в websocket-lanes нарушение протокола в одной полосе убивает только её, а в https-lanes — всю сессию.
| Свойство | https | https-lanes | websocket | websocket-lanes |
|---|---|---|---|---|
| Соединений | 0 постоянных | 0 постоянных | 1 | по одному на поток |
| Состояние повторов | одно на сессию | своё на полосу | заменено гарантиями канала | заменено гарантиями канала |
| Головная блокировка | есть | нет | нет на уровне HTTP | нет |
| Ошибка в одной полосе | полос нет | рвёт всю сессию | полос нет | рвёт только полосу |
| Требует HTTP/2 | нет | фактически да | нет | желательно |
Долгий ожидающий запрос и его тайминги
В режимах на HTTP приём устроен через долгое ожидание: клиент отправляет запрос и сервер держит его до 25 секунд, ожидая появления данных. Если данных не появилось, приходит пустой ответ, и клиент спрашивает снова.
Политика конкуренции здесь нетипичная: побеждает новый запрос. Когда приходит второй ожидающий запрос при уже припаркованном первом, новый забирает роль, а старый немедленно возвращается пустым ответом с неизменным курсором. Мотивация в комментарии кода: старое соединение, скорее всего, уже молча умерло, и отказывать новому было бы хуже. Для отправки правило противоположное — два одновременных запроса на отправку не допускаются, второй получает 503.
Надёжность обеспечивается двумя счётчиками. Отправка нумеруется последовательностью, начиная с единицы; сервер принимает либо следующий номер, либо побайтно идентичный повтор последнего подтверждённого. Приём использует курсор: повтор старого курсора воспроизводит ту же порцию байт в байт. Клиент продвигает курсор только после того, как полностью передал полученное приложению, поэтому потерянный ответ просто запрашивается заново.
Выдержки времени связаны жёстко, и при ручной настройке фронтенда их легко порвать: ожидание держится 25 секунд, поэтому таймауты веб-сервера перед реле обязаны быть заметно больше — конкретные значения и настройки nginx приведены в заметке про установку. Сессия переживает потерю транспорта в течение двух минут — это настраиваемый период, после которого фоновая уборка её закрывает; проверка идёт раз в 30 секунд, так что реальное окно составляет от двух до двух с половиной минут.
Две минуты выбраны не случайно: живой мост обновляет сессию каждым ожидающим запросом, его собственное окно повторов укладывается в этот срок, а протокольный слой Telegram Desktop и так сбрасывает соединения после 30–45 секунд тишины. Увеличивать это значение имеет смысл для мобильных клиентов, которые уходят в фон на минуты.
Что защищает страницу моста
Страница моста генерируется сервером целиком и содержит только один встроенный скрипт — ни внешних файлов, ни картинок, ни стилей. Политика безопасности содержимого перечисляет шестнадцать директив, из которых почти все запрещающие: сеть разрешена только к собственному адресу и веб-сокету того же хоста, скрипты — только с одноразовым случайным маркером, встраивание страницы разрешено только с числового локального адреса.
Скрипт намеренно не использует ничего из того, что урезают в ужесточённых встроенных браузерах: ни куки, ни хранилища, ни фоновых обработчиков, ни фреймов, ни доступа к устройствам. В проекте есть тест, который прямо ищет в сгенерированной странице упоминания десятков таких возможностей и падает, если находит хоть одно.
Адрес с пропуском стирается из истории браузера сразу после загрузки: перезагрузка страницы даст обычный сайт. Все запросы моста идут с явным указанием не отправлять учётные данные и не следовать перенаправлениям.
Эксплуатационное требование, которое важнее прочих: логирование адресов и заголовков должно быть выключено и на веб-сервере, и на реле. Пропуск едет в строке запроса, а в режимах веб-сокета токен сессии едет в заголовке подпротокола. Включённый журнал доступа с сырыми адресами равносилен записи паролей в открытом виде. В самом реле логирования запросов нет вообще: единственные записи в журнале — события старта, остановки и падения слушателя, причём даже текст ошибки намеренно не печатается, только её класс.
Расхождения между спецификацией и кодом
Полезно знать, если планируете писать свою реализацию:
- Ограничение в 64 байта на данные
PONGесть в коде, но не в спецификации. - Длительность долгого ожидания и период жизни сессии после обрыва числами не названы в самой спецификации протокола, хотя протокольно значимы: их приходится вычитывать из кода и примера конфигурации.
- Зарезервированные коды типов не определены: правила обработки неизвестного типа в спецификации нет, разбор пачки кадров такие кадры пропускает, отсеивает их только проверка формы.
- Веб-сокет одновременно числится и в списке того, что не входит в первую версию, и среди поддерживаемых режимов — противоречие внутри архитектурного документа, скорее всего след более ранней редакции.
- Период тишины перед закрытием сессии в двух местах архитектурного документа назван десятиминутным, ещё в двух — двухминутным; в коде, в README и в примере конфигурации реализовано второе.
- Арифметическое переполнение окна архитектурный документ относит к фатальным ошибкам, но код вместо этого насыщает сумму до максимума 32-битного числа и сессию не рвёт.
- Размер кадра
DATAот реле спецификация ограничивает 64 КиБ, но склейка соседних кадров в очереди позволяет ему дорасти до максимума кадра в 1 МиБ. - Служебные адреса состояния и метрик в спецификации отсутствуют — они живут на отдельном локальном слушателе.
📚 См. также
- WEB-прокси Telegram: трафик внутри обычного сайта — обзорная заметка: что это, зачем и можно ли пользоваться сейчас.
- Установка tproxy-server: свой WEB-прокси на своём домене — практика развёртывания, требования к фронтенду и подводные камни установщика.
- Выдача WEB-прокси из Telegram-бота — что из описанного здесь придётся хранить на стороне бота.
- WSS для Telegram: MTProto внутри WebSocket — другой транспорт с похожей идеей, но без мультиплексирования и без своего сервера.
- 🔗 PROTOCOL.md в репозитории проекта — первоисточник спецификации.
🤖 Эти статьи открыты — можно обучать на них ИИ
При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование доступно в Forgejo: исходник этой заметки · скачать весь репозиторий одним zip-архивом.