Как стать автором
Поиск
Написать публикацию
Обновить

Как я сделал Telegram-канал с автопостингом лучших обсуждений Hacker News

Уровень сложностиПростой

Мне нравится Hacker News - одно из главных мест в интернете, где рождаются тренды. Публика там обитает подкованная. Всегда интересно почитать комментарии к обсуждаемой теме.

На русском языке переводов я не нашел (но нередко встречаю публикации на основе тредов HN), а постоянный просмотр ленты и комментариев слегка утомляют. Да и читать всё подряд — невозможно.

Я решил автоматизировать свою потребность: написал алгоритм, который находит лучшие обсуждения и отправляет их в Telegram. Сперва отправлял себе в личку, затем решил, что логичнее будет сделать общедоступный канал.
В статье поделюсь, как всё сделано - от запуска стека до формулы ранжирования и автопубликации через n8n.

LM-ирония: ChatGPT решил, что я лысоват
LM-ирония: ChatGPT решил, что я лысоват

Инструменты

Поигравшись в облачной версии 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+)">(.+?)(?:&nbsp;)?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-ноде.

Этот этап решает сразу несколько задач:

  1. Очистка текста — декодирует HTML-сущности (&lt;&gt;&amp;) и нормализует все тире до дефисов — чтобы текст выглядел опрятно и без «мусора» в Telegram;

  2. Повторная дедупликация по URL — если по одной и той же ссылке пришло несколько версий поста (например, при повторном сканировании), остаётся только самая свежая;

  3. Оценка значимости — по ключевым словам из заранее заданных тематических групп (катастрофы, смерть, война, технологии, цензура, релизы и т. д.). Каждое ключевое слово даёт вес от 0.5 до 1.0. Если в тексте нет ничего значимого — пост получит низкий приоритет;

  4. Оценка свежести — чем меньше времени прошло с момента публикации, тем выше коэффициент свежести;

  5. Подсчет финального score: 0.7 * значимость + 0.3 * свежесть.

Это позволяет на выходе получить самую интересную и актуальную новость, а не просто популярную. По итогам такого ранжирования остаётся только один пост, который и уходит в Telegram через юзербот Telethon.

Итого

Воркфлоу работает по расписанию пять дней в неделю, выбирая топовое обсуждение Hacker News и превращая его в читабельный пост на русском языке. Я в целом доволен. В перспективе думаю улучшить промт (да и LM сменить), чтобы было интереснее читать и находить более глубокие инсайты в комментариях.

Канал, куда всё публикуется: @hnrussian
Без флуда - только выжимка самых обсуждаемых постов Hacker News на русском.

P.S. Я специалист по коммуникациям, не разработчик. Этот мини-проект — результат практического интереса и большого количества вайбкоддинг-граблей. Поэтому с радостью приму конструктивную критику.

Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.