码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js3.8 KB
/**
 * 内置插件:RSS 订阅推送
 * --------------------------------------------------
 * 在「插件市场 → RSS 订阅 → 配置」里添加 RSS/Atom 订阅源后,
 * 用户向机器人发送「最新」即可自动推送最新 3 条文章(标题 + 链接)。
 *
 * 零依赖:使用 axios 抓取、正则解析 RSS/Atom,无需额外安装 XML 库。
 */
const db = require('../../lib/db');
const axios = require('axios');

module.exports = {
  meta: {
    id: 'rss',
    name: 'RSS 订阅推送',
    version: '1.0.0',
    author: '奶狗',
    category: '信息获取',
    description: '可添加多个 RSS/Atom 订阅源(名称 + 地址),发送「最新」即推送各订阅源最新 3 条文章。',
    entry: 'rss/index.js',
    builtin: true,
    // 专用配置 UI:由前端渲染「名称 + 订阅地址」的多个订阅源管理,而非通用 key-value 表单
    customConfig: 'rss_feeds',
  },

  async onMessage(msg, ctx) {
    const text = (msg.content || '').trim();
    // 触发指令:精确发送「最新」
    if (text !== '最新') return false;

    const botId = ctx.bot.id;
    const feeds = await db.rows(
      'SELECT id, title, feed_url FROM plugin_rss WHERE bot_id=? ORDER BY id DESC',
      [botId]
    );
    if (!feeds.length) {
      await ctx.sendText('[RSS] 尚未配置 RSS 订阅源,请在「插件市场 → RSS 订阅 → 配置」中添加。');
      return true;
    }

    let sent = 0;
    const MAX_TOTAL = 5;
    for (const f of feeds) {
      if (sent >= MAX_TOTAL) break;
      try {
        const xml = await fetchFeed(f.feed_url);
        const items = parseFeed(xml).slice(0, 3);
        for (const it of items) {
          if (sent >= MAX_TOTAL) break;
          const prefix = f.title ? '【' + f.title + '】\n' : '';
          const body = '📰 ' + it.title + (it.link ? '\n' + it.link : '');
          await ctx.sendText(prefix + body);
          sent++;
        }
      } catch (e) {
        console.error('[rss] 拉取失败', f.feed_url, e.message);
      }
    }

    if (sent === 0) {
      await ctx.sendText('[!] 暂时未能拉取到 RSS 内容,请检查订阅源地址是否有效。');
    }
    return true;
  },
};

/** 抓取订阅源文本 */
async function fetchFeed(url) {
  const { data } = await axios.get(url, {
    timeout: 8000,
    responseType: 'text',
    headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' },
  });
  return typeof data === 'string' ? data : data.toString();
}

/** 解析 RSS 或 Atom,返回 [{title, link}] */
function parseFeed(xml) {
  const out = [];

  const collect = (blockRe, titleRe, linkRe) => {
    let m;
    while ((m = blockRe.exec(xml))) {
      const block = m[1];
      const t = (block.match(titleRe) || [])[1] || '';
      let link = (block.match(linkRe) || [])[1] || '';
      if (!link) {
        // Atom: <link href="..."/>
        link = (block.match(/<link[^>]+href="([^"]+)"/) || [])[1] || '';
      }
      if (t) out.push({ title: clean(t), link: clean(link) });
    }
  };

  // RSS 2.0: <item><title>..</title><link>..</link>
  collect(
    /<item[\s>]([\s\S]*?)<\/item>/g,
    /<title[^>]*>([\s\S]*?)<\/title>/,
    /<link[^>]*>([\s\S]*?)<\/link>/
  );

  // Atom: <entry><title>..</title><link href=".."/>
  if (!out.length) {
    collect(
      /<entry[\s>]([\s\S]*?)<\/entry>/g,
      /<title[^>]*>([\s\S]*?)<\/title>/,
      /<link[^>]*>([\s\S]*?)<\/link>/
    );
  }

  return out;
}

/** 去除 CDATA、解码常见实体、压缩空白 */
function clean(s) {
  return s
    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;|&apos;/g, "'")
    .replace(/\s+/g, ' ')
    .trim();
}