- BrainTools - https://www.braintools.ru -
Мы привыкли видеть Telegram как список чатов и каналов на своём устройстве. Но это лишь малая часть большой экосистемы, ограниченная подписками каждого пользователя. А как выглядит вся экосистема целиком?
Чтобы ответить на этот вопрос, мы — команда сервиса TGpages [1], провели масштабное исследование публичных Telegram-каналов. Результатом исследования стала интерактивная карта [2], которая поможет пользователям находить новые интересные каналы, а авторам, экспертам и брендам — лучше понимать структуру рынка, находить ниши и анализировать конкурентное окружение.
Эта статья — о том, как мы провели исследование и разработали карту. Я постарался описать методику исследования и основные технические барьеры, которые нам удалось решить в этом проекте.

Задача создать карту, на которой пользователям будет легко находить похожие каналы, ниши, оценивать масштаб тематических категорий, и немаловажно — чтобы её было интересно исследовать.
Представьте человека, который впервые смотрит на звёздное небо через телескоп. Каждое скопление звёзд — загадка. Человек изучает небо, открывает для себя новые галактики и созвездия. Нам хотелось, чтобы на нашей карте Telegram-каналов пользователь тоже мог «путешествовать» по тематическим «галактикам», открывая для себя новые «звёзды» — каналы.
Чтобы этого добиться, в основу карты было заложено два принципа:
Если каналы пишут схожий контент, они находятся рядом на карте
Чем дальше каналы друг от друга, тем меньше у них общего
— так на карте возникают целые острова и тематические архипелаги.
Чтобы пользователю проще было найти на карте интересующие тематики, мы решили разметить каждый канал тематической категорией, к которой он относится.
Было решено ввести двухуровневую тематическую категоризацию:
Макрокатегории — широкие тематические области: «Новости», «Развлечения», «Крипта», «Спорт»,…
Микрокатегории — узкие тематические ниши: «Разработка игр и геймдизайн», «Новости и фанаты LEGO», «Строительство бань и саун», «Сообщества мам», «Кожевенное мастерство»,…

Такой подход к тематическому моделированию помог сделать карту удобнее: пользователь начинает с общего обзора всей экосистемы, находит интересующую его область и постепенно погружается в детали. Точно так же устроена навигация в бумажных атласах — от континентов к странам, от стран к городам.
Список каналов мы собрали из нескольких источников:
Собственная база нашего сервиса — TGPages [1]
Открытые каталоги каналов
Курируемые списки каналов от наших партнёров
В итоге мы получили около 600 000 каналов для исследования.
Чтобы собрать данные о 600 000 каналах за разумное время, и не столкнуться с блокировками со стороны Telegram, мы построили распределённую систему на облачных функциях (AWS Lambda). Архитектура выглядит так:
Центральный бэкенд хранит очередь заданий (usernames каналов) и структурированные метаданные каналов
Lambda-воркеры в 17 регионах AWS параллельно считывают данные каналов с веб-интерфейса Telegram
S3 хранит «сырые» данные каналов — публикации
Каждый воркер:
Получает задание от центрального API
Загружает публичное веб-превью канала (https://t.me/s/{username})
Парсит HTML, извлекает метаданные и текст до 100 последних постов
Загружает аватар канала в хранилище с CDN (будет нужен для интерактивной карты)
Сохраняет посты в S3 для дальнейшего анализа
При скорости около 10 запросов в минуту для каждого воркера обработка всех 600 000 каналов заняла 2-3 дня.
Для каждого канала извлекаем:
Метаданные: название, описание (bio), аватар, количество подписчиков
Контент: тексты постов (до 100 последних публикаций)
Статистика: просмотры, реакции [3], даты публикаций
Не все каналы подошли для анализа:
Каналы без публичного веб-превью (https://t.me/s/{username})
Каналы с недостаточным количеством текста
Каналы с контентом преимущественно из изображений/видео без текста
После фильтрации для дальнейшего анализа осталось около 500 000 каналов.
📚 Что такое эмбеддинг? Представьте, что каждый текст — это точка в пространстве. Похожие тексты находятся близко друг к другу, разные — далеко. Эмбеддинг — это координаты этой точки. Нейросеть читает текст и выдаёт список чисел (в нашем случае — вектор из 1536 числел) — это и есть «адрес» текста в многомерном семантическом пространстве. Тексты про гейминг будут иметь похожие координаты, а тексты про кулинарию — совсем другие.
Для тематического моделирования и построения двухмерной карты, необходимо было «понять» и представить в числовой форме о чём каждый канал — построить эмбеддинги. Есть два источника информации [4]:
Как канал себя позиционирует — название и описание (bio)
О чём канал фактически пишет — тексты постов
Для каждого канала мы сгенерировали два отдельных эмбеддинга с помощью модели OpenAI text-embedding-3-small — для контента канала и для названия и описания.
Два отдельных эмбеддинга мы сделали, чтобы можно было в дальнейшем «регулировать» насколько каждый из них играет роль в процессе кластеризации и построении карты
Генерация эмбеддингов для 500 000 каналов — это около 1 000 000 запросов к API (два эмбеддинга на канал). Чтобы снизить затраты, мы использовали OpenAI Batch API (-50% к стоимости использования). Весь процесс вычисления эмбеддингов занял около 3 дней.
У нас получилось два 1536-мерных вектора для каждого канала. Для кластеризации и построения карты нужен только один. Мы использовали взвешенное среднее с последующей нормализацией:
# Комбинируем с равным весом 50/50
combined = 0.5 * identity_emb + 0.5 * content_emb
# L2-нормализация: приводим к единичной длине
combined = combined / np.linalg.norm(combined)
Что здесь происходит:
Взвешенное сложение. Каждый эмбеддинг — это вектор из 1536 чисел. Мы берём 50% от каждого компонента идентификационного эмбеддинга и 50% от контентного, складываем их. Получается новый вектор, который «помнит» оба сигнала. Вес 50/50 — эмпирический выбор. Мы пробовали разные соотношения (0.3/0.7, 0.7/0.3) и визуально оценивали качество кластеров.
L2-нормализация. Исходные эмбеддинги от OpenAI имеют единичную длину. Но после сложения двух векторов длина результата зависит от угла между ними: если контентный и идентификационный эмбеддинги указывают в одном направлении (канал пишет о том же, о чём заявляет) — длина близка к 1; если они расходятся — длина меньше. Мы приводим все векторы к единичной длине, чтобы это не влияло на кластеризацию. После нормализации евклидово расстояние между векторами становится эквивалентно косинусному сходству — метрике широко применяемой для сравнения текстов.
📚 Что такое L2-нормализация? Представьте стрелку из центра координат. L2-нормализация растягивает или сжимает её так, чтобы длина стала ровна 1. После этого мы сравниваем только направления векторов, а не их величины. Два текста разной длины после нормализации сравниваются по семантике.
📚 Кластеризация — процесс автоматической группировки объектов на основе их схожести. Представьте карту с точками — кластеризация находит «сгустки» и говорит: «вот это одна группа, а вот это — другая». Алгоритм не знает заранее, сколько групп искать — он просто находит структуру в данных.
Теперь у нас получилось 500 000 комбинированных векторов по 1536 измерений каждый. Но кластеризовать их «как есть» нельзя — мешает «проклятие размерности».
📚 Что такое «проклятие размерности»? Представьте, что вы ищете похожего на себя человека. Если критерий один — рост — задача простая: найти людей примерно вашего роста. Если критериев два (рост и вес), уже сложнее, но всё ещё реально. А теперь представьте 1536 критериев: рост, вес, цвет глаз, любимый фильм, скорость печати, предпочтения в музыке… По каждому критерию люди немного отличаются, и эти маленькие различия накапливаются. В итоге все оказываются примерно одинаково «далеки» от вас — понятие «похожий» размывается. Хуже того: разница между «самым похожим» и «самым непохожим» человеком становится ничтожной на фоне общего «расстояния» до каждого из них.
Шаг 1: UMAP — снижение размерности. Алгоритм UMAP выолняет сжатие размерности эмбеддингов так чтобы сохранить локальную структуру в данных: каналы, которые были ближайшими в 1536-мерном пространстве эмбеддингов, останутся ближайшими и после сжатия. Мы «сжимаем» 1536 измерений до 15:
reducer = umap.UMAP(
n_components=15, # целевая размерность
n_neighbors=30, # сколько соседей учитывать
metric='cosine', # косинусное расстояние для текстов
min_dist=0.0, # разрешаем плотные кластеры
random_state=42
)
reduced = reducer.fit_transform(combined_embeddings)
Шаг 2: HDBSCAN — поиск кластеров. Был выбран HDBSCAN, потому что он не требует заранее указывать число кластеров — алгоритм сам находит группы на основе плотности данных и умеет помечать «шумовые» точки, которые не принадлежат ни одному кластеру:
# L2-нормализуем после UMAP
normalized = reduced / np.linalg.norm(reduced, axis=1, keepdims=True)
clusterer = hdbscan.HDBSCAN(
min_cluster_size=50, # минимальный размер кластера
min_samples=15, # плотность ядра кластера
metric='euclidean'
)
labels = clusterer.fit_predict(normalized)
Мы выполнили кластеризацию дважды с разными параметрами для получения двух уровней иерархии:
|
Параметр |
Глубокая кластеризация |
Широкая кластеризация |
|---|---|---|
|
|
50 |
500 |
|
|
15 |
100 |
|
Результат |
~650 микрокатегорий |
~100 макрокатегорий |
Глубокая кластеризация (с меньшим min_cluster_size) находит узкие ниши: «Крипто-трейдинг», «Турецкие сериалы», «Городские СМИ». Широкая (с большим min_cluster_size) — общие области: «Гейминг», «Бизнес», «Спорт».
HDBSCAN помечает неоднозначные точки как шум (метка -1). Мы классифицируем такие каналы в категорию «Разное». В результате, около 4% каналов со смешанной тематикой или слишком уникальным контентом попали в этот раздел.
Для использования в нашем проекте полученным группам необходимо было дать репрезентативные имена: «Лайфстайл и блоги», «Маркетинг», «Здоровье», «Психология» и так далее
📚 Что такое центроид кластера? Это «центр тяжести» кластера — усреднённый вектор всех точек в группе. Если кластер — это облако точек, центроид — точка в самом центре этого облака.
Для каждого кластера:
Выбрали самые репрезентативные каналы — 40 каналов, ближайших к центроиду по косинусному сходству
Собрали контент — название, описание (до 200 символов) и 3 самых длинных поста (до 800 символов каждый)
Отправили в GPT-5.2 Completion API с промптом похожим на следующий (сокращен для статьи):
Ниже представлены семплы из тематических категорий Telegram-каналов полученных в ходе процесса кластеризации.
На основе семпла данных Telegram-каналов необходимо сформировать названия для рубрик (например: 'Бизнес', 'Спорт', 'Кулинария', 'Видеоигры', 'Политика', 'Музыка'). Для каждого кластера каналов сгенерируй одно название категории (1–4 слова, на русском языке)
...
При обработке большого числа кластеров батчами неизбежно появляются похожие названия:
«Крипто-трейдинг»
«Торговля криптовалютой»
«Криптосигналы»
— это проблема, которую митигировали через семантическую дедупликацию:
Генерируем эмбеддинги всех названий категорий (тем же text-embedding-3-small)
Вычисляем попарные косинусные сходства
Для пар со сходством > 0.85 — перегенерировали название меньшего кластера, передав языковой модели список названий, которых нужно избегать.
Теперь нам нужно было спроецировать 500 000 каналов на 2D-карте так, чтобы запечатлеть семантическую близость: похожие каналы рядом, разные — далеко. Для этого мы используем алгоритм понижения размерности данных t-SNE. На входе алгоритма — эмбеддинги каналов, а на выходе мы получаем две координаты: x и y, которые будут использоваться для проекции маркера канала на карту.

Почему t-SNE, а не UMAP? Ведь UMAP мы уже использовали для кластеризации — почему бы не применить его и для 2D-проекции? Дело в визуальном эффекте. UMAP склонен создавать «вытянутые» связные структуры, где темы плавно перетекают друг в друга. t-SNE, напротив, агрессивно разделяет кластеры — между группами образуется чистое пустое пространство. Для нашей задачи это иде��льно: мы хотели сделать так, чтобы тематические категории выглядели как отдельные «острова».
Строго говоря, алгоритм t-SNE не гарантирует, что близость на плоскости будет отражать близость точек в исходном пространстве. Но для нашей задачи визуализации его было достаточно — тем более, что «подсветить» схожие каналы можно применив цветовое кодирование по тематическим категориям, выявленным на этапе кластеризации.
К этому моменту у нас появились все данные для построения карты: точки с 2D-координатами, именованные кластеры и метаданные каналов. Оставалось превратить это в интерфейс, в котором пользователь сможет исследовать данные — примерно как в Google Maps, но для Telegram-каналов.
Карта имеет три масштаба:
Обзорный уровень. Каналы отображаются как точки с цветовым кодированием по макрокатегориям, яркость точки — размер канала в подписчиках. Видны подписи с названием тематики на кластерах макрокатегорий.
Средний уровень. Цветовое кодирование для макрокатегорий переключается на кодирование для микрокатегорий. Появляются подписи кластеров микрокатегорий.
Глубокий уровень. Точки превращаются в «пузырьки» с аватарками каналов. Размер пузырька зависит от числа подписчиков — крупные каналы визуально выделяются.

При зуме карты переключение между уровнями анимировано: цвета точек плавно переходят от одного уровня цветового кодирования к следующему, подписи кластеров появляются и исчезают с fade-эффектом, размер точек изменяется постепенно приближаясь к глубокому уровню детализации. Для интерполяции цветовых значений и размеров используем кубические easing-функции от значения уровня зума, что делает анимацию приятнее для глаза.
Технически карта состоит из четырёх наложенных друг на друга слоёв:
WebGL-слой — отрисовка точек каналов
Canvas-слой для подписей — названия кластеров поверх карты для удобной навигации
Canvas-слой для аватарок каналов — на глубоком уровне зума поверх точек отображаются аватарки каналов
UI-слой — элементы интерфейса: строка поиска, фильтр катаегорий, карточки каналов и тултипы, модальные окна и пр.
Одним из главных вызовов этого проекта была отрисовка полумиллиона точек с плавной анимацией и приемлемой частотой кадров. В противном случае пользоваться картой было бы просто неудобно. Canvas для этого не подходит: каждый вызов arc() и fill() — это отдельная операция на CPU и на среднем по мощности устройстве можно ожидать FPS не больше 10.
Решение — WebGL. Вместо того чтобы рисовать точки по одной, мы загружаем все координаты в GPU одним массивом и выполняем отрисовку за один draw call. GPU спроектирован именно для таких задач — параллельной обработки множества вершин.
Ключевое решение — статический буфер. Все 500 000 точек загружаются в видеопамять один раз при старте приложения. Каждый кадр мы передаём в шейдер только «глобальные» параметры — позицию камеры, уровень зума, время для анимаций — а координаты точек остаются в GPU.
На глубоком уровне масштаба ситуация другая: в видимую область попадает всего несколько десятков или сотен каналов, и для режима «пузырьков» нужны скорректированные позиции с разрешением коллизий (об этом далее). Для этого случая мы использовали пространственный индекс, чтобы быстро найти только видимые точки (viewport culling), пересчитывать их позиции и загружать в GPU небольшой буфер для отрисовки.
Координаты каналов (точек на карте) были получены в ходе расчета t-SNE, но как понять на каких координатах размещать подписи названий кластеров? Наивный ответ — в центроиде (среднем арифметическом координат всех точек кластера). Но t-SNE создаёт кластеры неправильной формы. Большой кластер может состоять из нескольких плотных «островов», и подпись в геометрическом центре окажется в пустоте между ними.
Решение — density-based positioning:
Разбиваем точки кластера на grid-ячейки
Находим ячейки с максимальной плотностью
Размещаем подписи в «визуальных центрах» — там, где больше всего точек
Для крупных кластеров (более 500 каналов) показываем сразу несколько подписей в разных плотных областях с достаточным количеством точек. Чтобы не загружать устройство пользователя этими вычислениями, расчет выполняется «оффлайн» — на этапе сборки проекта.

Когда пользователь водит курсором по карте, необходимо показывать тултип с информацией о канале на который указывает курсор. При клике на точку нужно открыть окно с подробным описанием канала. Для обеих задач нужно находить идентификатор канала под курсором, чтобы извлечь его информацию. Наивным решением было бы перебирать все 500 000 точек чтобы ближайшую к курсору. Но это O(n) на каждое событие мыши, что даст заметные лаги для такого количества точек. Решение которое мы применили — пространственный индекс.
📚 Что такое пространственный индекс? Представьте шахматную доску, наложенную на карту. Каждая клетка «знает», какие точки в ней находятся. Когда пользователь кликает, мы сначала определяем клетку под курсором, а затем проверяем только точки в этой клетке и соседних. Вместо 500 000 точек проверяем лишь малую долю из них.
Мы использовали grid-based индекс с ячейками размером 0.05 координатных единиц (координаты точек в пределах [-1, 1]):
class SpatialIndex {
private grid: Map<number, GridCell> = new Map();
private cellSize: number = 0.05;
getCellKey(x: number, y: number): number {
const cx = Math.floor(x / this.cellSize) + OFFSET;
const cy = Math.floor(y / this.cellSize) + OFFSET;
return cx + cy * MULTIPLIER; // Компактный числовой ключ
}
}
При таком подходе поиск занимает O(k), где k — количество каналов в нескольких ячейках. Этот же индекс мы затем использовали для построения списка похожих каналов в карточке канала.
На глубоком зуме нашей карты каналы отображаются как «пузырьки» с аватарками. Размер «пузырька» зависит от числа подписчиков. При реализации мы столкнулись с проблемой: пузырьки крупных каналов перекрывают мелких соседей. Нужно было придумать как «расталкивать» пересекающиеся «пузырьки» каналов, чтобы все были видны на карте.
Для этого мы использовали алгоритм итеративной релаксации:
function relaxPositions(channels: Channel[], zoom: number, baseScale: number, iterations = 12) {
const scale = baseScale * zoom;
const padding = 3 / scale;
const springStrength = 0.08;
const channelData = channels.map(ch => ({
id: ch.id,
originalX: ch.x,
originalY: ch.y,
x: ch.x,
y: ch.y,
radius: getRadius(ch.normalizedSize, zoom) / scale,
}));
for (let iter = 0; iter < iterations; iter++) {
// Расталкиваем пересекающиеся пузырьки
for (let i = 0; i < channelData.length; i++) {
for (let j = i + 1; j < channelData.length; j++) {
const a = channelData[i];
const b = channelData[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = a.radius + b.radius + padding;
if (dist < minDist && dist > 0.0001) {
const overlap = minDist - dist;
const push = overlap * 0.5;
a.x -= (dx / dist) * push;
a.y -= (dy / dist) * push;
b.x += (dx / dist) * push;
b.y += (dy / dist) * push;
}
}
}
for (const ch of channelData) {
ch.x -= (ch.x - ch.originalX) * springStrength;
ch.y -= (ch.y - ch.originalY) * springStrength;
}
}
return channelData;
}

Симуляция запускается когда пользователь панорамирует или меняет масштаб карты на глубоком уровне зума с debounce 150мс и завершается за 12 итераций. За счет debounce, пока пользователь активно зумит, пузыри остаются на исходных позициях — это предотвращает излишнее «дёргание» интерфейса и улучшает производительность.
📚 Что такое debounce? Это техника, при которой ресурсоёмкие вычисления откладываются во времени до тех пор, пока пользователь не прекратит взаимодействие. Если пользователь в данный момент меняет масштаб или позицию на карте, мы не запускаем тяжёлые вычисления. Жд��м 150 мс после того как пользователь закончил и только тогда пересчитываем позиции пузырьков.
500 000 каналов с метаданными (название, описание, координаты, кластеры, размер) — это десятки мегабайт данных. Мы разбили данные на 15 чанков. Формат — MessagePack вместо JSON. Это бинарный формат, который парсится браузером быстрее. Стратегия загрузки — prefetch с перекрытием: пока мы обрабатываем текущий чанк, уже запрашиваем следующий. Сетевое ожидание и обработка данных происходят параллельно.
Мы подумали, что пользователи наверняка захотят делиться найденным на карте контентом — как собственным каналом, так и новыми интересными находками. Для этого состояние карты должно полностью восстанавливаться из URL.
Мы сохраняем все параметры в query-строке:
/?x=-0.362722&y=0.522171&z=115&channel=e1ad3ff8-35ec-4bd3-9da4-14c0d3dae6e4&zoom=115
x, y — координаты центра viewport
z — уровень зума
channel — выбранный канал
search — поисковый запрос
При каждом изменении состояния (панорамирование, зум, выбор канала) URL обновляется. При открытии ссылки происходит обратный процесс: парсим query-параметры, восстанавливаем позицию камеры и, если указан канал, открываем карточку с информацией.
В результате у нас получилось большое исследование экосистемы Telegram-каналов с визуально впечатляющим и при этом функциональным интерфейсом. Мы получили интерактивную карту с 500 000 телеграм каналами классифицированные на ~100 макро- и ~600 микрокатегорий. Карта доступна у нас на сайте https://tgpages.com/atlas/map [2]. Мы будем дополнять карту новыми каналами и улучшать качество анализа данных.
Вопросы или предложения по реализации карты пишите в комментариях. Если вы автор Telegram-канала и не нашли свой канал на карте, вы можете добавить его через нашего бота [6] и он попадёт в следующую версию карты.
Автор: BorisChumichev
Источник [7]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/25207
URLs in this post:
[1] TGpages: https://tgpages.com?utm_source=habr
[2] интерактивная карта: https://tgpages.com/atlas/map?utm_source=habr
[3] реакции: http://www.braintools.ru/article/1549
[4] источника информации: http://www.braintools.ru/article/8616
[5] Image: https://sourcecraft.dev/
[6] бота: https://t.me/tgatlas_bot
[7] Источник: https://habr.com/ru/articles/992910/?utm_campaign=992910&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.