const Parser = require('rss-parser');
const axios = require('axios');
const cheerio = require('cheerio');
const rssParser = new Parser({
timeout: 10000,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; KZBot/1.0)' }
});
function log(msg) {
const ts = new Date().toLocaleString('en-CA', { timeZone: 'America/Vancouver' });
console.log(`[${ts}] ${msg}`);
}
// Получить новости из RSS-источников канала
// usedUrls — глобальный dedup: URL уже отправленные в ЛЮБОЙ канал (из БД)
// sources — если передан, использует его; иначе берёт из channel.sources (fallback)
// Перемешать массив (Fisher-Yates) — чтобы лента не была всегда из первого источника
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
async function fetchNews(channel, usedUrls = new Set(), sources) {
// Ротация источников: случайный порядок каждый вызов → разнообразие новостей
const srcList = shuffle(sources || channel.sources || []);
for (const source of srcList) {
try {
if (source.type === 'rss') {
const item = await fetchRSS(source, channel, usedUrls);
if (item) return item;
}
} catch (err) {
log(`⚠️ Ошибка парсинга ${source.name}: ${err.message}`);
require('../error-log').addError(channel.id || 'unknown', 'rss', `${source.name}: ${err.message}`);
}
}
return null;
}
// Ключевые слова релевантности для КЗ (используются в городских каналах)
const KZ_RELEVANCE_WORDS = [
'казахстан', 'казахский', 'казахстанск', 'алматы', 'астана', 'алма-ата',
'тенге', 'токаев', 'мажилис', 'сенат', 'нацбанк', 'нур-султан',
'шымкент', 'караганд', 'актау', 'актобе', 'атырау', 'павлодар',
'семей', 'костанай', 'кызылорда', 'тараз', 'уральск', 'петропавловск',
'казах', 'tengri', 'tengrinews', 'kz', 'қазақ',
'минздрав', 'минобразования', 'мвд', 'казмунайгаз', 'казатомпром',
'kaspi', 'халык', 'forte', 'jusan', 'freedom finance',
'кпл', 'барыс', 'головкин', 'рахмонов', 'елеусинов', 'рыбакина',
'балхаш', 'арал', 'байконур', 'медеу', 'чарын', 'алатау'
];
function isKZRelevant(text) {
const lower = text.toLowerCase();
return KZ_RELEVANCE_WORDS.some(w => lower.includes(w));
}
// Парсинг RSS фида
async function fetchRSS(source, channel, usedUrls) {
const feed = await rssParser.parseURL(source.url);
if (!feed.items || feed.items.length === 0) return null;
// Фильтруем по городу если это новостной канал
const cityFilter = channel.city ? channel.city.toLowerCase() : null;
// Расширенные ключевые слова городов для точной фильтрации
const cityKeywords = {
'алматы': ['алматы', 'алма-ата', 'алатау', 'медеу', 'кок-тобе', 'коктобе', 'алмалинск', 'бостандык', 'алматинск'],
'астана': ['астана', 'нур-султан', 'нурсултан', 'байтерек', 'экспо-городок', 'левый берег', 'левобережь']
};
const rivalCityKey = channel.city === 'Алматы' ? 'астана' : channel.city === 'Астана' ? 'алматы' : null;
const rivalWords = rivalCityKey ? cityKeywords[rivalCityKey] : [];
// Для городских каналов: сначала собираем КЗ-релевантные, потом мировые как fallback
const localItems = [];
const worldItems = [];
for (const item of feed.items.slice(0, 20)) {
const title = item.title || '';
const link = item.link || '';
const content = item.contentSnippet || item.content || '';
// Глобальный dedup — пропускаем URL отправленные в другие каналы
if (link && usedUrls.has(link)) continue;
// Для городских каналов — фильтр по городу-конкуренту (расширенный)
if (cityFilter && rivalWords.length > 0) {
const text = `${title} ${content}`.toLowerCase();
const mentionsRival = rivalWords.some(word => text.includes(word));
// Пропускаем новости про город-конкурент
if (mentionsRival) continue;
}
const parsed = { title, link, content, item };
// Для городских каналов — разделяем на местные и мировые
if (cityFilter) {
const text = `${title} ${content}`;
if (isKZRelevant(text)) {
localItems.push(parsed);
} else {
worldItems.push(parsed);
}
} else {
// Для не-городских каналов — все новости равны
localItems.push(parsed);
}
}
// Приоритет: сначала КЗ-новости, потом мировые как fallback
const sorted = [...localItems, ...worldItems];
for (const { title, link, content, item } of sorted) {
// Извлекаем OG-image
let imageUrl = null;
if (item.enclosure && item.enclosure.url) {
imageUrl = item.enclosure.url;
} else {
imageUrl = extractImageFromContent(item.content || '');
}
// Если нет картинки из RSS — пробуем спарсить со страницы
if (!imageUrl && link) {
imageUrl = await fetchOGImage(link);
}
return {
title,
url: link,
content: content.substring(0, 500),
imageUrl,
source: source.name,
sourceId: source.id || null,
pubDate: item.pubDate || item.isoDate
};
}
return null;
}
// Извлечь картинку из HTML контента RSS
function extractImageFromContent(html) {
if (!html) return null;
const $ = cheerio.load(html);
const img = $('img').first().attr('src');
return img || null;
}
// Получить OG-image со страницы
async function fetchOGImage(url) {
try {
const { data } = await axios.get(url, {
timeout: 5000,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; KZBot/1.0)' }
});
const $ = cheerio.load(data);
const ogImage = $('meta[property="og:image"]').attr('content')
|| $('meta[name="twitter:image"]').attr('content');
return ogImage || null;
} catch {
return null;
}
}
module.exports = { fetchNews };
📜 Git History
08548e3feat: живые промпты каналов + ротация источников (B1+B2)3 weeks ago
dc171bdfix: 6 улучшений — источники, фильтры, контент, обрезка7 weeks ago
0413c12fix: 9 багов — caption limit, orphan cleanup, security hardening7 weeks ago
045688afeat: dashboard v0.3 — 9 новых источников + 10 улучшений дашборда7 weeks ago
5481deffeat: post moderation queue for new sources7 weeks ago
6ff5fa6feat: source management with moderation via dashboard modal7 weeks ago
2757087init: KZ Channels bot — 5 Telegram channels for Kazakhstan7 weeks ago
Show last diff
Loading...