← Back
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...