Привет, жители Хабра!
Прежде чем начнем, ответьте мне на вопрос: где вы сейчас читаете эту статью? По пути на работу, в автобусе, метро - или в комфортной обстановке в определенно выделенное время?
Мы живем в удивительное время: казалось бы, рутинные задачи требуют от нас минимального количества внимания и энергии, но даже при этом времени не остается и эти задачи приходится делегировать, автоматизировать или просто откладываем на потом.
По роду деятельности и просто из любопытства я постоянно штудирую горы статей про языковые модели, ML, DL и т.д, чтобы быть на острие технологий. Но, как и у всех, иногда нет возможности не то чтобы читать статьи, а просто просматривать и откладывать их на будущее. Поэтому сначала я решил выделять определенное время на неделе для чтения, но быстро понял, что к этому времени желательно иметь уже подготовленный материал. Замкнутый круг.
В итоге я нашел решение настолько же простое, насколько эффективное. Мой личный ИИ-помощник на базе n8n и языковой модели теперь экономит мне несколько часов в неделю. Задумка такая же простая, как и реализация:
Подготовить список источников, с которых мне интересно собирать статьи + по возможности найти RSS для удобного доступа
Выставить весовые коэффициенты каждому источнику в зависимости от его важности по моему личному мнению
Подготовить список 'позитивных' и 'негативных' ключевых слов, которые могут содержать статьи
Рассчитать вес каждой статьи в зависимости от количества 'позитивных' слов и веса источника
Проранжировать статьи и оставить первые n
Проанализировать n статей с помощью LLM на предмет соответствия моим интересам и полезности, выставить балл от 0 до 100
Отправить топ статей с баллом и кратким содержанием в Telegram
Я выбрал именно такую последовательность шагов предварительной обработки, так как хотел найти оптимальный баланс между точностью и количеством потребляемых токенов модели.
В этой статье я покажу:
Как подключить https для self hosted n8n
Как использовать GigaChat в своих проектах
Как использовать DataTables вместо Google таблиц
Как автоматизировать сбор статей и отправку топа в телеграмм
Так же в своем телеграмм ��анале я выложу пост о том как подключить ollama для тех, кто хочет полной автономности и там же будет ссылка на готовый проект.
Начинаем начинать
Начнем сразу с одной из самых интересных тем - как установить безопасный туннель для проектов, которые работают на локальных ПК. Для тех кто не в курсе: такое соединение необходимо для подключения некоторых сторонних сервисов, например, Telegram.
Несколько лет назад это не вызывало проблем, мы бы просто использовали ngrok, но, насколько я помню, в 2023 году он начал блокировать российские ip адреса, и этот путь для нас закрылся. Я нашел несколько отечественных аналогов и выбрал CloudPub, так как он предоставляет бесплатный ежемесячный пакет трафика.
Установка CloudPub
Первым делом зайдите на страницу документации CloudPub. Там вы найдете варианты для установки: можно поставить графическое приложение или командную утилиту.
Я выбрал приложение — с ним проще работать, если вы не привыкли к командной строке.

В качестве порта укажите 5678 - стандартный порт для n8n. Этих действий достаточно для запуска своих проектов через https.
WorkFlow
Теперь, когда туннель создан, он предоставил вам уникальный внешний адрес (например, https://your-subdomain.cloudpub.ru). Скопируйте его — сейчас он нам понадобится.
$env:WEBHOOK_URL='https://steadily-liberal-squeaker.cloudpub.ru/'; n8n
Прежде чем мы двинемся дальше, давайте я покажу, как будет выглядеть результат. Когда мы настроим все узлы, вы увидите примерно следующее:

Создание DataTables
Теперь перейдем к хранилищу наших данных. Обратите внимание на вкладку Data Tables рядом с Workflows — это встроенная альтернатива Google Таблицам, доступная в n8n версии 1.1 и выше.
Я предпочитаю использовать именно их, так как Data Tables не требуют никаких внешних сертификатов и не делают запросов к сторонним сервисам. Весь процесс идет внутри n8n -> работает быстрее и надежнее.
Нам понадобится создать одну колонку. Перейдите на вкладку и добавьте новую колонку, которая будет отвечать за ссылку на источник (source_link):

Подключение Telegram
Для работы с мессенджером необходимо создать бота с помощью @BotFather - официального бота для разработчиков. Найдите его в телеграмме и создайте своего бота. После создания вы получите Access token по типу:
8012365473:AAHGaw32X3z4TapjkpAWVq1hGHpgFWeCCf4
Перейдите в n8n и добавьте Telegram -> Triggers -> On message

Откройте настройки узла, добавьте новый аккаунт и вставьте туда Access token из BotFather.
Добавьте узел с чтением записей из Data Table, чтобы не обрабатывать новости дважды.

Source List / Positive & Negative words
Далее я создаю блок, в котором указываю все необходимые для парсинга ссылки и списки ключевых слов. Позже объясню, зачем они нужны. В качестве источников я выбрал Хабр, AiTrends и разные хабы на arXiv.
В первом списке указаны ссылки, которые ведут к RSS API, во втором ссылки на ленты со статьями:
const rss = [
{ name: "AI Trends", url: "https://www.aitrends.com/feed/", weight: 1.0},
// Добавленные arXiv RSS источники
{ name: "arXiv AI", url: "https://export.arxiv.org/api/query?search_query=cat:cs.AI&max_results=15&sortBy=submittedDate&sortOrder=descending", weight: 1.5 },
{ name: "arXiv NLP & LLM", url: "https://export.arxiv.org/api/query?search_query=cat:cs.CL&max_results=15&sortBy=submittedDate&sortOrder=descending", weight: 1.5 },
{ name: "arXiv Machine Learning", url: "https://export.arxiv.org/api/query?search_query=cat:cs.LG&max_results=15&sortBy=submittedDate&sortOrder=descending", weight: 1.5 },
];
const pages = [
{ name: "Habr", url: "https://habr.com/ru/hubs/artificial_intelligence/articles/", weight: 1.5 },
]
Ключевые слова разделены на категории:
const keywords = {
'positive': [
'AI', 'Large language models', 'LLM', 'generative ai', 'artificial intelligence', 'python', 'python3', 'anti fraud', 'cyber security', 'programming', 'n8n', 'openAi', 'gigachat',
'ai', 'ml', 'machine learning', 'llm', 'openai', 'chatgpt', 'gemini', 'anthropic', 'openrouter', 'rag', 'fine-tuning', 'ChatGpt', 'mcp',
'ИИ', 'ии', 'промпт', 'промпты', 'промптинг', 'искусственный интеллек', 'кибербезопасность', 'автоматизаций', 'GPT', 'гпт',
'нейросеть', 'нейросети',
],
'negative': [
'crypto', 'cryptocurrency', 'bitcoin', 'nft', 'blockchain', 'web3', 'metaverse', 'startup funding', 'venture capital', 'investment',
'marketing', 'sales', 'seo', 'growth hacking', 'e-commerce', 'influencer',
'gaming', 'game development', 'gamedev', 'console', 'playstation', 'xbox', 'iphone', 'android', 'gadget', 'review', 'wearable', 'smart home', 'film', 'movie', 'tv series', 'music', 'artwork', 'hollywood',
'politics', 'government', 'election', 'trump', 'biden',
'ocaml', 'haskell', 'lisp', 'erlang', 'quantum computing', 'research paper', 'academic',
'driver', 'ffmpeg', 'medical', 'healthcare', 'biology', 'geographical', 'social media', 'Microsoft 365', 'Windows 11', 'dovecot', 'cloudflare',
'казино', 'криптовалюты', 'биткоин', 'игры', 'продажи', 'маркетинг', 'фильм', 'драйвер', 'фьючерс'
]
};
const incomingItems = $items();
const processedUrls = incomingItems.length > 0 ? incomingItems[0].json.processedUrls : [];
Возвращаем обьект, который будет доступен в дальнейших узлах:
return [{
sources: {
sources_rss: rss,
sources_pages: pages
},
filters: keywords,
processedUrls: processedUrls
}];
Отдельно выделяем ссылки источников с помощью узла Split Out. Не забудьте указать название поля:

Data collecting
Code node с RSS запросами и парсингом статей. Я не буду здесь подробно разбирать код, так как это стаей не про JavaScript, только вкратце опишу его работу:
Разделение источников: входные данные делятся на RSS-ленты и обычные HTML-страницы.
Обработка RSS: для каждого RSS/Atom-фида скачивается XML, извлекаются записи /, фильтруются только свежие статьи.
Обработка HTML-страниц: для хабов загружаются страницы, из HTML вытаскиваются статьи и кандидаты-ссылки; при необходимости скрипт догружает метаданные по каждой найденной ссылке.
Формирование результата: статьи очищаются, объединяются, дедуплицируются и возвращаются в структурированном виде.

Скрытый текст
const helpers = this.helpers;
/* ====== Настройки ====== */
const DAYS = 7;
const MAX_PAGES = 12;
const MAX_ARTICLE_META_FETCH = 40;
const REQUEST_TIMEOUT = 20000;
const POLITE_PAUSE_MS = 250;
/* ======================= */
const clean = s => (s === undefined || s === null) ? '' : String(s).replace(/\s+/g, ' ').trim();
const sleep = ms => new Promise(r => setTimeout(r, ms));
const isRecent = (dateStr, cutoff) => {
if (!dateStr) return false;
const d = new Date(dateStr);
return !isNaN(d.getTime()) && d >= cutoff;
};
// HTTP GET (n8n helper)
async function fetchFull(url, headers = {}, timeout = REQUEST_TIMEOUT) {
const defaultHeaders = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Referer': 'https://habr.com/',
...headers
};
return helpers.request({
uri: url,
method: 'GET',
headers: defaultHeaders,
timeout,
json: false,
resolveWithFullResponse: true,
followRedirect: true,
});
}
// Resolve relative URL to absolute (tries native URL, fallback manual)
function resolveUrl(href, base) {
if (!href) return href;
if (/^https?:\/\//i.test(href)) return href;
if (/^\/\//.test(href)) {
const proto = (String(base).match(/^(https?:)/i) || ['','https:'])[1];
return proto + href;
}
try {
if (typeof URL !== 'undefined') return String(new URL(href, base));
} catch (e) { /* fallback */ }
try {
const m = String(base).match(/^(https?:\/\/[^\/]+)(\/.*)?$/i);
if (!m) return href;
const origin = m[1];
let path = m[2] || '/';
if (!path.endsWith('/')) path = path.substring(0, path.lastIndexOf('/') + 1) || '/';
if (href.startsWith('/')) return origin + href;
const stack = (path + href).split('/');
const parts = [];
for (const part of stack) {
if (part === '..') {
if (parts.length) parts.pop();
} else if (part === '.' || part === '') {
// skip
} else {
parts.push(part);
}
}
return origin + '/' + parts.join('/');
} catch (e) {
return href;
}
}
/* ====== RSS/Atom parser (минимальный, regex) ======
Возвращает массив { title, link, pubDate, description } */
function parseRssFeed(body, baseUrl) {
const s = String(body);
const out = [];
// RSS <item>
let m;
const itemRe = /<item[\s\S]*?<\/item>/gi;
while ((m = itemRe.exec(s)) !== null) {
const block = m[0];
const title = (block.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || ['',''])[1].replace(/<[^>]+>/g,'').trim();
const linkRaw = (block.match(/<link[^>]*>([\s\S]*?)<\/link>/i) || ['',''])[1].trim();
const link = linkRaw ? resolveUrl(linkRaw, baseUrl) : (block.match(/<guid[^>]*>([\s\S]*?)<\/guid>/i)||['',''])[1].trim();
const pub = (block.match(/<pubDate[^>]*>([\s\S]*?)<\/pubDate>/i) || ['',''])[1].trim();
const descRaw = (block.match(/<description[^>]*>([\s\S]*?)<\/description>/i) || ['',''])[1] || '';
// decode simple entities and strip HTML tags for description
const description = descRaw.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/<[^>]+>/g,'').trim();
if (title && link) out.push({ title, link, pubDate: pub, description });
}
// Atom <entry>
const entryRe = /<entry[\s\S]*?<\/entry>/gi;
while ((m = entryRe.exec(s)) !== null) {
const block = m[0];
const title = (block.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || ['',''])[1].replace(/<[^>]+>/g,'').trim();
const linkAttr = (block.match(/<link[^>]*href=["']([^"']+)["'][^>]*>/i) || ['',''])[1];
const link = linkAttr ? resolveUrl(linkAttr, baseUrl) : (block.match(/<id[^>]*>([\s\S]*?)<\/id>/i)||['',''])[1].trim();
const pub = (block.match(/<(?:updated|published)[^>]*>([\s\S]*?)<\/(?:updated|published)>/i) || ['',''])[1].trim();
const descRaw = (block.match(/<summary[^>]*>([\s\S]*?)<\/summary>/i) || ['',''])[1] || '';
const description = descRaw.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/<[^>]+>/g,'').trim();
if (title && link) out.push({ title, link, pubDate: pub, description });
}
return out;
}
/* ====== HTML parsers (для хабов) ====== */
function parseArticleBlocks(html, baseUrl) {
const blocks = (String(html).match(/<article[\s\S]*?<\/article>/gi) || []);
const items = [];
for (const b of blocks) {
const timeMatch = b.match(/<time[^>]*datetime=["']([^"']+)["']/i);
const pubDate = timeMatch ? timeMatch[1] : '';
const titleMatch = b.match(/<a[^>]*class=["'][^"']*(?:post__title_link|tm-article-snippet__title-link|post__title|tm-article-snippet__title)[^"']*["'][^>]*>([\s\S]*?)<\/a>/i)
|| b.match(/<h[1-6][^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/i);
const hrefMatch = b.match(/<a[^>]*href=["']([^"']+)["'][^>]*class=["'][^"']*(?:post__title_link|tm-article-snippet__title-link|post__title|tm-article-snippet__title)[^"']*["']/i)
|| b.match(/<h[1-6][^>]*>[\s\S]*?<a[^>]*href=["']([^"']+)["']/i);
const excerptMatch = b.match(/<div[^>]*class=["'][^"']*(?:post__text|tm-article-snippet__lead|post__body|tm-article-snippet__content)[^"']*["'][^>]*>([\s\S]*?)<\/div>/i)
|| b.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
const title = titleMatch ? clean((titleMatch[1]||'').replace(/<[^>]+>/g,'')) : '';
let link = hrefMatch ? hrefMatch[1] : '';
if (link) link = resolveUrl(link, baseUrl);
const excerpt = excerptMatch ? clean((excerptMatch[1]||'').replace(/<[^>]+>/g,'')) : '';
if (title && link) items.push({ title, link, pubDate, description: excerpt, guid: link || title });
}
return items;
}
function extractCandidateLinks(html, baseUrl) {
const map = new Map();
const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
let m;
while ((m = re.exec(html)) !== null) {
let href = m[1];
const txt = clean((m[2]||'').replace(/<[^>]+>/g,''));
try { href = resolveUrl(href, baseUrl); } catch (e) { continue; }
if (href.match(/\/post\/|\/ru\/post\/|\/ru\/company\/|\/company\//i) && !href.match(/\.(png|jpe?g|gif|svg|webp)(?:[?#]|$)/i)) {
const nohash = href.split('#')[0];
if (!map.has(nohash)) map.set(nohash, txt || nohash);
}
}
return Array.from(map.entries()).map(([link,title]) => ({ link, title }));
}
async function fetchArticleMeta(url) {
try {
const resp = await fetchFull(url, { 'Accept': 'text/html' }, REQUEST_TIMEOUT);
const body = String(resp.body || '');
let pub = '';
const t = body.match(/<time[^>]*datetime=["']([^"']+)["']/i);
if (t) pub = t[1];
if (!pub) {
const m = body.match(/<meta[^>]*(?:property|name)=["'](?:article:published_time|og:published_time|date)["'][^>]*content=["']([^"']+)["']/i);
if (m) pub = m[1];
}
const title = (body.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) || body.match(/<title[^>]*>([\s\S]*?)<\/title>/i) || ['',''])[1] || '';
const desc = (body.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) || ['',''])[1] || '';
return { ok: true, url, status: resp.statusCode, contentType: resp.headers && resp.headers['content-type'], length: body.length, pubDate: pub ? clean(pub) : '', title: clean(title), description: clean(desc) };
} catch (err) {
return { ok: false, url, error: (err && err.message) ? err.message : String(err) };
}
}
/* ====== Входные конфиги (поддерживаем новый и старый формат) ====== */
let rawInputs = ($input && $input.all && $input.all().length) ? $input.all().map(i => i.json) : [];
let rssConfigs = [];
let pageConfigs = [];
// Case A: new format as single array item containing two objects (first = rss, second = pages)
// Example: $input.all()[0].json === [ { "0": {...}, "1": {...} }, { "0": {...} } ]
if (rawInputs.length === 1 && Array.isArray(rawInputs[0])) {
const arr = rawInputs[0];
if (arr.length >= 1 && typeof arr[0] === 'object' && !Array.isArray(arr[0])) {
// first group: numeric keys -> RSS
const first = arr[0];
rssConfigs = Object.keys(first).map(k => first[k]).filter(Boolean);
const second = arr[1];
if (second && typeof second === 'object') pageConfigs = Object.keys(second).map(k => second[k]).filter(Boolean);
} else {
// fallback: treat as flat list of feeds
rssConfigs = arr.filter(x => x && x.url).slice();
}
} else {
// Case B: rawInputs is list of config objects (old style) OR two objects (new style but already split)
// If rawInputs[0] is an object with numeric keys, assume rawInputs[0] = rss group, rawInputs[1] = pages group
if (rawInputs.length >= 2 && typeof rawInputs[0] === 'object' && Object.keys(rawInputs[0]).every(k => /^\d+$/.test(k))) {
rssConfigs = Object.keys(rawInputs[0]).map(k => rawInputs[0][k]).filter(Boolean);
pageConfigs = rawInputs[1] && typeof rawInputs[1] === 'object' ? Object.keys(rawInputs[1]).map(k => rawInputs[1][k]).filter(Boolean) : [];
} else {
// Otherwise consider all as generic feeds (try RSS first, will fallback to HTML scraping)
const flat = rawInputs.flatMap(x => Array.isArray(x) ? x : [x]);
// split by heuristics: urls containing '/feed' or 'export.arxiv.org' -> rss, else pages
for (const c of flat) {
if (!c || !c.url) continue;
if (/\/feed\/|\/rss\/|export\.arxiv\.org|\/api\/query/i.test(c.url)) rssConfigs.push(c);
else pageConfigs.push(c);
}
}
}
// If nothing provided, fallback example
if (rssConfigs.length === 0 && pageConfigs.length === 0) {
rssConfigs = [{ name: 'AI Trends', url: 'https://www.aitrends.com/feed/', weight: 1 }];
pageConfigs = [{ name: 'Habr', url: 'https://habr.com/ru/hubs/artificial_intelligence/articles/', weight: 1 }];
}
/* ====== MAIN: сначала RSS/Atom, затем HTML ====== */
const finalResults = [];
const cutoff = new Date(Date.now() - DAYS * 24 * 3600 * 1000);
// 1) Обработка RSS/Atom конфигов
for (const fc of rssConfigs) {
try {
const { name, url } = fc || {};
let resp;
try {
resp = await fetchFull(url, { 'Accept': 'application/rss+xml,application/atom+xml,text/xml' }, REQUEST_TIMEOUT);
} catch (err) {
// пропускаем неудачный фид
continue;
}
const body = String(resp.body || '');
const feedItems = parseRssFeed(body, url);
for (const it of feedItems) {
if (it.link && it.pubDate && isRecent(it.pubDate, cutoff)) {
finalResults.push({
json: {
title: it.title,
link: it.link,
pubDate: it.pubDate,
description: it.description || '',
guid: it.link,
source: name || url,
weight: fc.weight || 1
}
});
}
}
// короткая пауза между фидами
await sleep(POLITE_PAUSE_MS);
} catch (e) {
// игнор ошибок и идём дальше
}
}
// 2) Обработка HTML-хабов (по аналогии с исходным кодом)
for (const fc of pageConfigs) {
const { name, url } = fc || {};
const hubCutoff = cutoff;
const seen = new Set();
let hubBase = url;
try {
const m = String(url).match(/\/ru\/hubs\/([^\/]+)/i);
if (m && m[1]) hubBase = `https://habr.com/ru/hubs/${m[1]}/articles/`;
if (!hubBase.endsWith('/')) hubBase += '/';
} catch (e) { hubBase = url; }
const candidatesCollected = [];
const articlesFound = [];
for (let p = 1; p <= MAX_PAGES; p++) {
const pageUrl = (p === 1) ? hubBase : `${hubBase}page${p}/`;
let resp;
try {
resp = await fetchFull(pageUrl, { 'Accept': 'text/html' }, REQUEST_TIMEOUT);
} catch (err) {
break;
}
const body = String(resp.body || '');
// Если это случайно XML/Feed, можно попытаться распарсить как фид
const ctype = (resp.headers && resp.headers['content-type']) || '';
const looksLikeFeed = /xml|rss|atom/i.test(ctype) || /\/feed|\/rss|\/api\/query/i.test(pageUrl);
if (looksLikeFeed) {
const items = parseRssFeed(body, pageUrl);
for (const it of items) {
if (!seen.has(it.link) && it.pubDate && isRecent(it.pubDate, hubCutoff)) {
seen.add(it.link);
articlesFound.push({ title: it.title, link: it.link, pubDate: it.pubDate, description: it.description || '' });
} else if (!it.pubDate) {
candidatesCollected.push({ link: it.link, title: it.title });
}
}
await sleep(POLITE_PAUSE_MS);
continue;
}
// HTML: parse <article> blocks
const blocks = parseArticleBlocks(body, pageUrl);
for (const b of blocks) {
if (seen.has(b.link)) continue;
if (b.pubDate && isRecent(b.pubDate, hubCutoff)) {
seen.add(b.link);
articlesFound.push({ title: b.title, link: b.link, pubDate: b.pubDate, description: b.description || '' });
} else if (!b.pubDate) {
candidatesCollected.push({ link: b.link, title: b.title });
}
}
// также собираем candidate links из <a>
const candidates = extractCandidateLinks(body, pageUrl);
if (candidates.length) {
candidatesCollected.push(...candidates.slice(0, 20));
}
await sleep(POLITE_PAUSE_MS);
} // end pages
// Доп. fetch метаданных по кандидатам
const uniqueCandidates = Array.from(new Map(candidatesCollected.map(c => [c.link, c])).values());
let metaFetchCount = 0;
for (const c of uniqueCandidates) {
if (metaFetchCount >= MAX_ARTICLE_META_FETCH) break;
if (!c || !c.link) continue;
if (seen.has(c.link)) continue;
await sleep(120);
const meta = await fetchArticleMeta(c.link);
metaFetchCount++;
if (meta && meta.ok && meta.pubDate && isRecent(meta.pubDate, hubCutoff)) {
seen.add(c.link);
articlesFound.push({ title: meta.title || c.title, link: c.link, pubDate: meta.pubDate, description: meta.description || '' });
}
}
// Добавляем найденные статьи в общий результат
for (const a of articlesFound) {
finalResults.push({
json: {
title: a.title,
link: a.link,
pubDate: a.pubDate || '',
description: a.description || '',
guid: a.link,
source: name || hubBase,
weight: fc.weight || 1
}
});
}
}
// Если ничего не найдено, возвращаем пустой массив (или можно вернуть диагностические данные)
return finalResults;
Первичная обработка
Далее идут несколько узлов с обработкой и нормализацией данных:
Проверяем наличие записей в уже обработанных
Удаляем статьи, которые содержат слова из списка негативных
Вычисляем процент схожести двух строк (заголовков) на основе расстояния Левенштейна. Это сделано для удаления похожих постов из разных источников.

После обработки нам удалось сократить количество записей с 136 до 105, следовательно в дальнейшем мы потратим меньше токенов для обработки с помощью LLM.
P.S: можете попробовать удалить блок с нормализацией и сравнить результат
После первичного отбора у нас все еще может оставаться несколько сотен статей. Сократить их до приблизительно 100 статей простым обрезанием списка - плохая идея, так как можно вырезать полезный материал.
Вместо этого мы рассчитаем рейтинг (score) для каждой статьи. Этот рейтинг будет зависеть от двух факторов:
Релевантность: Как часто «позитивные» ключевые слова встречаются в заголовке и описании.
Авторитет источника: Вес, который мы вручную выставили каждому сайту.

Подключение GigaChat
Я буду использовать модель от Сбера, так как ее можно использовать и оплачивать в России без использования 'схем' и криптовалюты. Благо пользователи уже сделали готовый узел для подключения к гиге.
-
Регистрация и получение Api токена. Перейдите на сайт документации:
https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/gigachat-api
Сбер дает бесплатные пакеты на все модели, в частности 1 млн токенов на lite версию, которую мы будем использовать.
-
Установка пользовательского узла GigaChat:
-
Перейдите в Settings -> Community nodes и добавьте туда n8n-nodes-gigachat. Я нашел этот узел на просторах интернета, ссылка на авторов: https://github.com/tomyumm-ge/n8n-gigachat?tab=readme-ov-file
После этого в доступных узлах появится GigaChat, в качестве модели выберите GigaChat-2

-
Добавьте узел Basic llm chain и подключите к нему узел с гигой, вставьте ключ, который получили на странице с документацией.
В результате у меня получилась цепочка, которая состоит из нескольких узлов:
Loop over item - позволяет обрабатывать по 1 записи за раз, так как на бесплатном тарифе доступен 1 поток
Basic llm chain с GigaChat, который извлекает содержимое
Code node, который преобразует ответ в json и удаляет лишние символы

Prompt запрос
Теперь самый важный шаг, который определит качество всего вашего дайджеста. Промпт — это инструкция для ИИ, и от его точности на 90% зависит полезность результата, поэтому не жалейте времени на его написание
Моя задача для GigaChat:
Проанализировать статью и поставить оценку от 0 до 100, где 100 — идеальное совпадение с моими интересами.
Ключевые критерии оценки:
Тематическое соответствие: Насколько статья связана с моей сферой.
Практическая ценность: Содержит ли она конкретные кейсы, инструкции, полезные инсайты.
Приоритеты: Максимальные баллы ставятся за статьи про применение LLM в банковской сфере и автоматизацию бизнес-процессов.
Ниже — мой рабочий промпт. Используйте его как основу и обязательно адаптируйте под ваши собственные задачи и интересы.
<role>
Ты - профессиоанальный Python разработчик с 10 летним опытом в области разработки нейронных сетей, языковых моделей и автоматизации с помощью AI агентов
</role>
<task>
Твоя задача - оценить предложенную новость по 100 бальной шкале по уровню практической полезности для AI разработчика который пишет код
</task>
<input_format>
id: {{ $json.link }}
title: {{ $json.title }}
content: {{ $json.preview }}
<input_format>
<output_format>
Верни ответ СТРОГО в формате JSON-объектс ключами:
1. `id`: id
3. `title`: title на русском
4. `ai_score`: число от 0 до 100.
5. `reason`: короткое (1-2 предложения) и честное объяснение, почему поставлена такая оценка, с точки зрения пользы для ИТ-директора на русском.
Example:
{
"id": "https://site.com/pg/",
"ai_score": 95,
"title": "Новая архитектура разработки AI гаентов с примером практической реализации",
"reason": "Красткое описание новости..."
}
</output_format>
<instructions>
1. Внимательно проанализируй новость
2. Оцени насколько эта новость относится к миру IT и в особенности к сфере AI/NLP
3. Поставь оценку для этой новости.
3.1 Критерии оценки:
90-100: Новость содержит информацию прооь практическое использование LLM/NLP/AI-агентов в автоматизации банковских процессов, в особенности в сфере противодействия мошенничеству (анти фрод)
Например, это может быть новость про использование LLM для обьяснения решений анти-фпрод моделей или новые паттерны проектирования AI агентов и так далее. ВАЖНО: НОВОСТЬ ДОЛЖНА СОДЕРЖАТЬ ПРАКТИЧЕСКИЕ ПРИМЕРЫ
80-90: Новости связанные с автоматизацией процессов с помощью LLM в других областях, не связанных с анти фродом. Например, новости про генерацию отчетов с помощью LLM или использование
для работы аналитиков, новости связанные с prompt запросами (context engineering), а также другая автоматизация с языковыми моделями. ВАЖНО: НОВОСТЬ ДОЛЖНА СОДЕРЖАТЬ ПРАКТИЧЕСКИЕ ПРИМЕРЫ
70-80: Новости связанные с новыми возможностями языка python, архитектурой приложений, микросервисами, библиотеками Langchain, langgraph, fastapi. Новости про языковыую модель GigaChat, использование LLM в банковской сфере, развитие отрасли AI связанной с NLP.
ВАЖНО: НОВОСТЬ ДОЛЖНА СОДЕРЖАТЬ ПРАКТИЧЕСКИЕ ПРИМЕРЫ
60-70: Новсти связанные с миром ML/DL и классической разработкой на Python. Эта категория может не содержать практических примеров
50-60: Новости связанные с миром IT, технологий, бизнеса. Эта категория может не содержать практических примеров
0-50: Новосяти свзяанные с другими сферамами, которые не относятся к вышеперечисленным
4. Старайся делать более детальную оценку новостей последовательно добавляя баллы.
5. Тебе не интересны статьи связанные с вайбкодингом, использованием сред разработки с ИИ,
чат боты, UI интерфейсы и написание на других языках
5. Верни ответ СТРОГО в формате JSON-объектс ключами:
1. `id`: id
3. `title`: title на русском
4. `ai_score`: число от 0 до 100.
5. `reason`: короткое (1-2 предложения) и честное объяснение, почему поставлена такая оценка, с точки зрения пользы для ИТ-директора на русском.
</instructions>
Я удалил блок запроса, который содержит примеры оценивания, так как он занимает довольно много места.
Вот примеры ответов, которые генерирует GigaChat после анализа статей. Как видите, модель не просто ставит балл, но и кратко обосновывает свое решение:

Все обработанные записи сохраняем Data Tables:

Фильтруем данные по ai acore, оставляем первые 30. Не забывайте указывать названия полей в настройках узлов:

Финальный акт: отправляем дайджест
Мы собрали данные, провели предварительную обработку, обработали их с помощью LLM. Осталось дело за малым - сформировать результат и отправить его в telegram
Блок кода, который форматирует ответ:
function escapeMarkdownV2(text) {
// Экранирование специальных символов для MarkdownV2
const chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
let escapedText = String(text ?? '');
chars.forEach(char => {
escapedText = escapedText.split(char).join(`\\${char}`);
});
return escapedText;
}
const items = $items();
const header = 'Топ IT\\-новостей\\n\\n';
const lines = items.map((item, index) => {
const title = escapeMarkdownV2(item.json.title);
const link = item.json.id;
const score = Math.round(item.json.ai_score || 0);
const reason = escapeMarkdownV2(item.json.reason);
// Форматирование для MarkdownV2 с ссылками
return `${index + 1}\\. \\[${score}/100\\] [${title}](${link}) — ${reason}`;
});
const MAX = 3500;
const messages = [];
let buf = header;
for (const line of lines) {
if ((buf + '\\n' + line).length > MAX) {
messages.push(buf.trim());
buf = '';
}
buf += (buf ? '\\n' : '') + line;
}
if (buf) messages.push(buf.trim());
return messages.map(message => ({
json: {
telegram_message: message,
parse_mode: 'MarkdownV2'
}
}));
Узел Telegram для отправки сообщений:

Укажите тот же токен что и в первом узле, выберите Parse Mode - MarkdownV2 для корректной обработки текста
Пример ответа нашей системы:
Скрытый текст
Топ IT-новостей: 1. [96/100] LLM не обязаны знать — LLM должны уметь. Andrej Karpathy подтвердил мою гипотезу (https://habr.com/ru/articles/959504) — Новость касается важной темы развития архитектуры AI-агентов и повышения эффективности взаимодействия LLM через возможность вызова внешних инструментов и циклического анализа результатов. Это крайне полезно для разработчиков, стремящихся улучшить качество и производительность AI-решений.n2. [92/100] Генеративный ИИ как штатный инженер техподдержки: настройка, внедрение, реальные ошибки (https://habr.com/ru/articles/959252) — Новость описывает реальный опыт внедрения генеративной модели в службу технической поддержки компании, подробно освещая процесс настройки контекста, проблемы интеграции и практические ошибки. Это ценная информация для разработчиков и инженеров, занимающихся внедрением генеративных моделей в рабочие процессы.n3. [92/100] T-LoRA: дообучить диффузионную модель на одной картинке и не переобучиться (https://habr.com/ru/companies/airi/articles/958348) — Новость посвящена новому подходу T-LoRA, который позволяет эффективно дообучать диффузионные модели на основе одной картинки, минимизируя риск переобучения. Это актуально для разработчиков нейросетей и пользователей диффузионных моделей, стремящихся быстро адаптировать их под конкретные задачи и данные.n4. [85/100] Парадокс безопасности локальных LLM (https://habr.com/ru/articles/960132) — Новость посвящена важной проблеме безопасности локально развернутых языковых моделей, что актуально для разработчиков и администраторов систем использующих подобные технологии. Статья предлагает конкретные результаты экспериментов и практические рекомендации по повышению безопасности, включая снижение рисков эксплуатации и внедрения вредоносного кода.n5. [85/100] Умный Early Stopping: обучаем нейросети, анализируя тренд, а не шум (https://habr.com/ru/articles/960078) — Новость предлагает улучшенный подход к Early Stopping, позволяющий......
Вместо заключения: давайте автоматизировать будущее
В этой статье я показал лишь один из сотен возможных способов заставить ИИ работать на себя. Как энтузиаст, я думаю, что главная магия и польза начинается тогда, когда мы перестанем воспринимать ИИ/LLM КАК игрушки и сделаем их частью повседневной жизни.
Я призываю вас экспериментировать, пробовать новое, так как каждая новая автоматизация и каждый новый человек в этой сфере - часть общего будущего. В следующих статьях я расскажу про другие процессы, которые мне удалось наладить с помощью n8n и своих проектов.
Подписывайтесь на мой канал, где я делюсь находками, новостями и готовыми решениями из мира ИИ.
Спасибо за прочтение!
MAXH0
Замечательно. Добавил в избранное и попробую реализовать.
Одно НО... Интересно, сколько будет стоить это решение, когда пробный период закончится и Сбер заставит платить за ресурсы по счетам. И можно поднять то же самое на локальных машинах с другими ИИ-чатами...
Viacheslav-hub Автор
Сбер даёт количество токенов без ограничения по времени и даже самостоятельно сбрасывает количество потраченных токенов)
Поднять LLM локально можно, но, если посчитать затраты, то это выйдет дороже