Как я сделал Telegram-канал с автопостингом лучших обсуждений Hacker News
Мне нравится Hacker News - одно из главных мест в интернете, где рождаются тренды. Публика там обитает подкованная. Всегда интересно почитать комментарии к обсуждаемой теме.
На русском языке переводов я не нашел (но нередко встречаю публикации на основе тредов HN), а постоянный просмотр ленты и комментариев слегка утомляют. Да и читать всё подряд — невозможно.
Я решил автоматизировать свою потребность: написал алгоритм, который находит лучшие обсуждения и отправляет их в Telegram. Сперва отправлял себе в личку, затем решил, что логичнее будет сделать общедоступный канал.
В статье поделюсь, как всё сделано - от запуска стека до формулы ранжирования и автопубликации через n8n.

Инструменты
Поигравшись в облачной версии n8n, понял, что нужна установка на виртуальной машине. Так как домены храню на таймвеб уже лет десять, решил и vps взять у них. Но через пару недель нашел действительно неплохой вариант (3 gpu / 8 ram/ 150 ssd) дешевле таймвеб в три раза, и перешел к новому провайдеру. Установил n8n через Docker.
Чтобы автоматизировать отбор и публикацию самых интересных тем, я собрал воркфлоу с кастомным ранжированием, MongoDB, Puppeteer и генерацией текста через Google Gemini Flash. Посты публикуются с помощью Telethon на FastAPI.
Для обхода загадочных ограничений Hacker News API я реализовал парсинг HTML-страниц с помощью HTTP-запросов. Воркфлоу каждые час (с 9:00 до 21:00 по МСК, с воскресенья по пятницу) запрашивает 5 первых страниц Hacker News ( ~150 постов).
Далее кастомный фильтр JS-код:
const html = items[0].json.data || '';
const baseUrl = 'https://news.ycombinator.com';
const now = new Date();
const nowMSK = new Date(now.getTime() + 3 * 60 * 60 * 1000);
function getRealAge(published, now) {
let diff = Math.max(0, now - published);
let min = Math.floor(diff / 1000 / 60);
let hours = Math.floor(min / 60);
min = min % 60;
let parts = [];
if (hours > 0) parts.push(`${hours} hours`);
if (min > 0 || hours === 0) parts.push(`${min} minutes`);
return parts.join(' ') + ' ago';
}
function getSnapshotAt() {
const now = new Date();
const nowMSK = new Date(now.getTime() + 3 * 60 * 60 * 1000);
return nowMSK.toISOString().replace('T', ' ').slice(0, 19);
}
const postBlocks = html.split('<tr class="athing submission"');
postBlocks.shift();
const results = [];
for (const blockRaw of postBlocks) {
const block = '<tr class="athing submission"' + blockRaw;
const idMatch = block.match(/id="(\d+)"/);
const id = idMatch ? idMatch[1] : null;
const titleMatch = block.match(/<span class="titleline">([\s\S]*?)<\/span>/);
let title = '', url = '';
if (titleMatch) {
const aMatch = titleMatch[1].match(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/);
if (aMatch) {
url = aMatch[1].startsWith('item?id=') ? baseUrl + '/' + aMatch[1] : aMatch[1];
title = aMatch[2];
}
}
const subtextMatch = blockRaw.match(/<td class="subtext">([\s\S]*?)<\/td>/);
let points = 0, age = null, date_published_utc = null, date_published_msk = null;
let commentsUrl = null, comments_count = 0, real_age = null;
if (subtextMatch) {
const sub = subtextMatch[1];
const pointsMatch = sub.match(/<span class="score"[^>]*>(\d+)\s+points?<\/span>/);
points = pointsMatch ? parseInt(pointsMatch[1]) : 0;
const ageBlock = sub.match(/<span class="age" title="([^"]+)"><a href="item\?id=\d+">([^<]+)<\/a><\/span>/);
if (ageBlock) {
date_published_utc = ageBlock[1].split(' ')[0];
age = ageBlock[2];
const publishedDate = new Date(date_published_utc + 'Z');
real_age = getRealAge(publishedDate, now);
const publishedMSK = new Date(publishedDate.getTime() + 3 * 60 * 60 * 1000);
date_published_msk = publishedMSK.toISOString().replace('T', ' ').slice(0, 19);
}
const commentsBlock = sub.match(/<a href="(item\?id=\d+)">(.+?)(?: )?comments?<\/a>/);
if (commentsBlock) {
commentsUrl = baseUrl + '/' + commentsBlock[1];
const numMatch = commentsBlock[2].match(/(\d+)/);
comments_count = numMatch ? parseInt(numMatch[1]) : 0;
} else {
const discussBlock = sub.match(/<a href="(item\?id=\d+)">discuss<\/a>/);
if (discussBlock) {
commentsUrl = baseUrl + '/' + discussBlock[1];
}
}
}
results.push({
id,
title,
url,
points,
real_age,
comments_count,
commentsUrl,
date_published_utc,
date_published_msk,
snapshot_at: getSnapshotAt()
});
}
return results.filter(post => post.points > 111);
код извлекает: заголовок, ссылку, дату публикации (UTC и МСК), кол-во очков и комментариев;
вычисляет реальный возраст поста по МСК(
real_age
);фильтрует всё, что набрало <111 баллов (решил, что 100 баллов маловато для отсева шума).
Алгоритм ранжирования
Следующий шаг - определить, какой из постов самый интересный в моменте. Для этого я сделал формулу, учитывающую:
очки (
points
);кол-во комментариев (
comments_count
);свежесть (
real_age
);соотношение комментариев к очкам (индикатор вовлечённости);
тему поста (AI, безопасность, программирование и т.д.).
function parseAgeToHours(ageStr) {
if (!ageStr) return Infinity;
const [value, unit] = ageStr.split(' ');
const num = parseInt(value, 10);
if (isNaN(num)) return Infinity;
switch (unit.toLowerCase()) {
case 'min': case 'mins': return num / 60;
case 'hour': case 'hours': return num;
case 'day': case 'days': return num * 24;
case 'week': case 'weeks': return num * 168;
default: return Infinity;
}
}
function getTopicFromTitle(title) {
if (!title) return 'Разное';
const lowerTitle = title.toLowerCase();
const topics = [
['Искусственный интеллект', ['ai', 'llm', 'ml', 'нейросеть', 'gpt', 'deep learning', 'transformer', 'chatbot', 'openai', 'autogen', 'inference']],
['Программирование', ['code', 'программирование', 'software', 'dev', 'python', 'java', 'c++', 'c#', 'rust', 'golang', 'compiler', 'debug', 'sdk', 'ide']],
['Веб-разработка', ['web', 'frontend', 'backend', 'css', 'html', 'javascript', 'react', 'vue', 'angular', 'typescript', 'dom', 'http', 'api']],
['Политика и данные', ['government', 'data', 'privacy', 'regulation', 'law', 'compliance', 'gdpr', 'surveillance', 'censorship']],
['Технологии и компании', ['microsoft', 'apple', 'google', 'amazon', 'tech', 'startup', 'mac', 'macbook', 'trackpad', 'hardware', 'device', 'gadget', 'robotics', 'chip', 'semiconductor']],
['Наука и исследования', ['science', 'research', 'physics', 'biology', 'chemistry', 'experiment', 'theory', 'academic', 'paper', 'discovery']],
['Бизнес и экономика', ['business', 'economy', 'finance', 'market', 'invest', 'valuation', 'stock', 'crypto', 'vc', 'funding']],
['Кибербезопасность', ['security', 'cyber', 'hack', 'exploit', 'malware', 'vulnerability', 'breach', 'ransomware']],
['Игры', ['game', 'gaming', 'console', 'steam', 'nintendo', 'playstation', 'xbox']],
['История и культура', ['history', 'culture', 'tour', 'review', 'archive', 'heritage', 'museum', 'story', 'memoir']]
];
for (const [topic, keywords] of topics) {
if (keywords.some(keyword => lowerTitle.includes(keyword))) {
return topic;
}
}
return 'Разное';
}
const HOUR_WINDOW = 24;
const POINTS_WEIGHT = 0.6;
const COMMENTS_WEIGHT = 0.4;
const TREND_WEIGHT = 0.5;
const scored = items.map(({ json }) => {
const { _source, ...cleanJson } = json; // Удаляем _source
const points = cleanJson.points || 0;
const comments = cleanJson.comments_count || 0;
const ageHours = parseAgeToHours(cleanJson.real_age);
const topic = getTopicFromTitle(cleanJson.title);
const baseScore = (POINTS_WEIGHT * points) + (COMMENTS_WEIGHT * comments);
const freshness = ageHours <= HOUR_WINDOW ? (HOUR_WINDOW - ageHours) / HOUR_WINDOW : 0;
const trendiness = points > 0 ? (comments / points) : 0;
const finalScore = baseScore * (1 + (freshness + TREND_WEIGHT * trendiness));
return {
...cleanJson,
topic,
baseScore,
freshness,
trendiness,
finalScore
};
});
scored.sort((a, b) => b.finalScore - a.finalScore || b.points - a.points || a.ageHours - b.ageHours);
const topArticle = scored[0];
return [{ json: topArticle }];
Нода возвращает строго 1 пост с наибольшим finalScore.
Извлечение статьи и комментариев
После отбора «поста-победителя», система делает три запроса:
Через Puppeteer (отдельный браузер, поскольку половина сайтов блокирует HTTP-запросы) получает HTML полной статьи;
Через HTTP + CSS-селекторы получает комментарии с HN (
.commtext
),Всё собирается в единый JSON через ноду Aggregate.
Генерация Telegram-поста
Затем создание поста и редактура:
JSON с текстом, комментами и метаданными поступает в ноду Basic AI-chain с моделью Google Gemini;
Используется заранее настроенный промт (пришлось помучаться) с простой структурой: «🔥 Тред», «📝 Что пишут?», «💬 Что говорят люди?»;
Gemini читает статью и комментарии, возвращает готовый HTML-пост, который в идеале можно публиковать без редактуры (но для Telegram понадобилась отдельная нода, которая правит HTML).
Сохранение и дедубликация
Чтобы не публиковать одно и то же, подключил MongoDB через встроенную ноду:
В коллекции хранятся все уже опубликованные посты;
При каждом запуске происходит проверка на дубли;
Только уникальный пост сохраняется в базу с флагом
completed_step
.
Финальный отбор: значимость + свежесть + чистота
Перед публикацией включается дополнительная фильтрация и ранжирование, реализованная в отдельной js
-ноде.
Этот этап решает сразу несколько задач:
Очистка текста — декодирует HTML-сущности (
<
,>
,&
) и нормализует все тире до дефисов — чтобы текст выглядел опрятно и без «мусора» в Telegram;Повторная дедупликация по URL — если по одной и той же ссылке пришло несколько версий поста (например, при повторном сканировании), остаётся только самая свежая;
Оценка значимости — по ключевым словам из заранее заданных тематических групп (катастрофы, смерть, война, технологии, цензура, релизы и т. д.). Каждое ключевое слово даёт вес от 0.5 до 1.0. Если в тексте нет ничего значимого — пост получит низкий приоритет;
Оценка свежести — чем меньше времени прошло с момента публикации, тем выше коэффициент свежести;
Подсчет финального score: 0.7 * значимость + 0.3 * свежесть.
Это позволяет на выходе получить самую интересную и актуальную новость, а не просто популярную. По итогам такого ранжирования остаётся только один пост, который и уходит в Telegram через юзербот Telethon.
Итого
Воркфлоу работает по расписанию пять дней в неделю, выбирая топовое обсуждение Hacker News и превращая его в читабельный пост на русском языке. Я в целом доволен. В перспективе думаю улучшить промт (да и LM сменить), чтобы было интереснее читать и находить более глубокие инсайты в комментариях.
Канал, куда всё публикуется: @hnrussian
Без флуда - только выжимка самых обсуждаемых постов Hacker News на русском.
P.S. Я специалист по коммуникациям, не разработчик. Этот мини-проект — результат практического интереса и большого количества вайбкоддинг-граблей. Поэтому с радостью приму конструктивную критику.