跳转至主要内容

NodeSeek 论坛关键词监控邮件通知 SMTP 版

ghostart

在 VPS 上部署 NodeSeek 关键词监控,到匹配到对应的帖子时发送邮件通知。

以下指令直接复制之后在 VPS 上粘贴回车执行即可,如果你不会用 vim 则替换成 nano

安装 Node.js

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion
nvm install 22
node -v; npm -v

初始化项目

mkdir ns-rss-keywords-smtp && cd $_
npm init -y
vim package.json
{
  "name": "ns-monitor",
  "version": "1.0.0",
  "description": "NodeSeek RSS 监控器 - 监控指定关键词并发送通知",
  "main": "main.js",
  "type": "module",
  "scripts": {
    "start": "node main.js",
    "test": "node test_main.js",
    "dev": "node --watch main.js"
  },
  "keywords": [
    "rss",
    "monitor",
    "nodeseek",
    "notification"
  ],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "axios": "^1.6.0",
    "dotenv": "^16.3.0",
    "fast-xml-parser": "^4.3.0"
  }
}

创建文件

Node.js 文件

vim main.js
#!/usr/bin/env node
// -*- coding: utf-8 -*-

import fs from "fs/promises";
import path from "path";
import axios from "axios";
import { XMLParser } from "fast-xml-parser";
import dotenv from "dotenv";
import nodemailer from "nodemailer";

/**
 * RSS监控器类
 */
class RSSMonitor {
  constructor() {
    // 加载 .env 文件
    dotenv.config();
    this.setupLogging();
    this.loadConfig();
    this.setupDataStorage();
  }

  /**
   * 设置日志配置
   */
  setupLogging() {
    // 确保data目录存在(使用本地目录)
    this.dataDir = "./data";
    this.logFile = path.join(this.dataDir, "monitor.log");

    // 创建日志方法
    this.logger = {
      info: (message) => this.log("INFO", message),
      error: (message) => this.log("ERROR", message),
      warning: (message) => this.log("WARNING", message),
      debug: (message) => this.log("DEBUG", message),
    };
  }

  /**
   * 日志记录方法
   */
  async log(level, message) {
    const timestamp = new Date()
      .toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
      .replace("T", " ")
      .substring(0, 19);
    const logMessage = `${timestamp} - ${level} - ${message}`;

    // 输出到控制台
    console.log(logMessage);

    // 确保data目录存在
    try {
      await fs.mkdir(this.dataDir, { recursive: true });
      // 追加到日志文件
      await fs.appendFile(this.logFile, logMessage + "\n", "utf8");
    } catch (error) {
      console.error(`日志写入失败: ${error.message}`);
    }
  }

  /**
   * 从环境变量加载配置
   */
  loadConfig() {
    try {
      // 获取关键词列表
      const keywordsEnv = process.env.KEYWORDS || "";
      if (keywordsEnv) {
        this.keywords = keywordsEnv
          .split(",")
          .map((kw) => kw.trim())
          .filter((kw) => kw.length > 0);
      } else {
        this.keywords = [];
      }

      // 获取 Gmail SMTP 配置
      this.gmailUser = process.env.GMAIL_USER || "";
      this.gmailPassword = process.env.GMAIL_APP_PASSWORD || "";
      this.notificationEmail = process.env.NOTIFICATION_EMAIL || "";

      // 获取检查间隔
      this.minInterval = parseInt(process.env.CHECK_MIN_INTERVAL || "20", 10);
      this.maxInterval = parseInt(process.env.CHECK_MAX_INTERVAL || "40", 10);

      // RSS URL
      this.rssUrl = "https://rss.nodeseek.com/";

      // 验证必要参数
      if (this.keywords.length === 0) {
        throw new Error("未设置关键词 (KEYWORDS)");
      }
      if (!this.gmailUser) {
        throw new Error("未设置 Gmail 邮箱地址 (GMAIL_USER)");
      }
      if (!this.gmailPassword) {
        throw new Error("未设置 Gmail 应用专用密码 (GMAIL_APP_PASSWORD)");
      }
      if (!this.notificationEmail) {
        throw new Error("未设置通知接收邮箱 (NOTIFICATION_EMAIL)");
      }

      // 初始化邮件发送器
      this.setupEmailTransporter();

      this.logger.info(
        `配置加载成功: 关键词=${this.keywords.join(", ")}, 间隔=${
          this.minInterval
        }-${this.maxInterval}秒, 通知邮箱=${this.notificationEmail}`
      );
    } catch (error) {
      this.logger.error(`配置加载失败: ${error.message}`);
      process.exit(1);
    }
  }

  /**
   * 设置邮件传输器
   */
  setupEmailTransporter() {
    this.transporter = nodemailer.createTransport({
      service: "gmail",
      auth: {
        user: this.gmailUser,
        pass: this.gmailPassword,
      },
    });
  }

  /**
   * 设置数据存储
   */
  setupDataStorage() {
    this.processedFile = path.join(this.dataDir, "processed_posts.json");
    this.processedPosts = new Set();
  }

  /**
   * 加载已处理的帖子ID
   */
  async loadProcessedPosts() {
    try {
      const fileExists = await fs
        .access(this.processedFile)
        .then(() => true)
        .catch(() => false);
      if (fileExists) {
        const content = await fs.readFile(this.processedFile, "utf8");
        const data = JSON.parse(content);
        this.processedPosts = new Set(data.processed_ids || []);
      }
    } catch (error) {
      this.logger.error(`加载已处理帖子记录失败: ${error.message}`);
    }
  }

  /**
   * 保存已处理的帖子ID
   */
  async saveProcessedPosts() {
    try {
      const data = {
        processed_ids: Array.from(this.processedPosts),
        last_update: new Date().toISOString(),
      };
      await fs.mkdir(this.dataDir, { recursive: true });
      await fs.writeFile(
        this.processedFile,
        JSON.stringify(data, null, 2),
        "utf8"
      );
    } catch (error) {
      this.logger.error(`保存已处理帖子记录失败: ${error.message}`);
    }
  }

  /**
   * 获取RSS内容
   */
  async fetchRss() {
    try {
      const headers = {
        "User-Agent":
          "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
      };

      const response = await axios.get(this.rssUrl, {
        headers,
        timeout: 30000,
        responseType: "text",
      });

      this.logger.debug("RSS获取成功");
      return response.data;
    } catch (error) {
      this.logger.error(`RSS获取失败: ${error.message}`);
      return null;
    }
  }

  /**
   * 解析RSS内容
   */
  parseRss(rssContent) {
    try {
      const parser = new XMLParser({
        ignoreAttributes: false,
        attributeNamePrefix: "@_",
      });

      const result = parser.parse(rssContent);
      const items = [];

      // 获取RSS中的item数组
      const rssItems = result.rss?.channel?.item || [];
      const itemArray = Array.isArray(rssItems) ? rssItems : [rssItems];

      for (const item of itemArray) {
        try {
          // 提取字段
          const title = item.title || "";
          const description = item.description || "";
          const link = item.link || "";
          const guid = item.guid?.["#text"] || item.guid || "";
          const pubdate = item.pubDate || "";
          const creator = item["dc:creator"] || "";

          if (guid) {
            items.push({
              id: guid,
              title,
              description,
              link,
              pubdate,
              creator,
            });
          }
        } catch (error) {
          this.logger.warning(`解析item失败: ${error.message}`);
        }
      }

      this.logger.info(`成功解析 ${items.length} 个帖子`);
      return items;
    } catch (error) {
      this.logger.error(`RSS解析失败: ${error.message}`);
      return [];
    }
  }

  /**
   * 检查文本中是否包含关键词
   */
  checkKeywords(text) {
    const foundKeywords = [];
    const textLower = text.toLowerCase();

    for (const keyword of this.keywords) {
      if (textLower.includes(keyword.toLowerCase())) {
        foundKeywords.push(keyword);
      }
    }

    return foundKeywords;
  }

  /**
   * 发送邮件通知
   */
  async sendNotification(title, content) {
    try {
      // 将 Markdown 格式的内容转换为 HTML
      const htmlContent = content
        .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
        .replace(/\n/g, "<br>");

      // 邮件选项
      const mailOptions = {
        from: this.gmailUser,
        to: this.notificationEmail,
        subject: title,
        text: content, // 纯文本版本
        html: `<div style="font-family: Arial, sans-serif; padding: 20px;">
          ${htmlContent}
        </div>`, // HTML 版本
      };

      // 发送邮件
      const info = await this.transporter.sendMail(mailOptions);
      this.logger.info(`邮件通知发送成功: ${info.messageId}`);
      return true;
    } catch (error) {
      this.logger.error(`邮件通知发送失败: ${error.message}`);
      return false;
    }
  }

  /**
   * 处理RSS项目
   */
  async processItems(items) {
    let newMatches = 0;

    for (const item of items) {
      const postId = item.id;

      // 检查是否已处理过
      if (this.processedPosts.has(postId)) {
        continue;
      }

      // 检查标题和描述中的关键词
      const titleKeywords = this.checkKeywords(item.title);
      const descKeywords = this.checkKeywords(item.description);

      const allKeywords = [...new Set([...titleKeywords, ...descKeywords])];

      if (allKeywords.length > 0) {
        // 发现匹配的关键词
        this.logger.info(
          `发现匹配帖子: ID=${postId}, 关键词=${allKeywords.join(", ")}`
        );

        // 准备通知内容
        const notificationTitle = `NS 关键词: ${allKeywords.join(
          ", "
        )}`;
        const descriptionPreview =
          item.description.length > 500
            ? item.description.substring(0, 500) + "..."
            : item.description;

        const notificationContent = `
**帖子标题**: ${item.title}

**匹配关键词**: ${allKeywords.join(", ")}

**帖子链接**: ${item.link}

**作者**: ${item.creator}

**发布时间**: ${item.pubdate}

**帖子描述**:
${descriptionPreview}
`;

        // 发送通知
        if (
          await this.sendNotification(notificationTitle, notificationContent)
        ) {
          // 标记为已处理
          this.processedPosts.add(postId);
          newMatches++;
          this.logger.info(`帖子 ${postId} 处理完成`);
        } else {
          this.logger.warning(`帖子 ${postId} 通知发送失败`);
        }
      }
    }

    if (newMatches > 0) {
      await this.saveProcessedPosts();
      this.logger.info(`本次检查发现 ${newMatches} 个新匹配帖子`);
    } else {
      this.logger.debug("本次检查无新匹配帖子");
    }
  }

  /**
   * 执行一次检查
   */
  async runOnce() {
    this.logger.info("开始执行RSS检查...");

    // 获取RSS内容
    const rssContent = await this.fetchRss();
    if (!rssContent) {
      this.logger.warning("无法获取RSS内容,跳过本次检查");
      return;
    }

    // 解析RSS
    const items = this.parseRss(rssContent);
    if (items.length === 0) {
      this.logger.warning("无法解析RSS内容,跳过本次检查");
      return;
    }

    // 处理项目
    await this.processItems(items);

    this.logger.info("RSS检查完成");
  }

  /**
   * 主运行循环
   */
  async run() {
    // 先加载已处理的帖子
    await this.loadProcessedPosts();

    this.logger.info("RSS监控器启动");
    this.logger.info(`监控地址: ${this.rssUrl}`);
    this.logger.info(`监控关键词: ${this.keywords.join(", ")}`);
    this.logger.info(`检查间隔: ${this.minInterval}-${this.maxInterval}秒`);

    // 设置中断信号处理
    process.on("SIGINT", () => {
      this.logger.info("收到中断信号,程序退出");
      process.exit(0);
    });

    while (true) {
      try {
        await this.runOnce();

        // 随机等待时间
        const waitTime =
          Math.floor(
            Math.random() * (this.maxInterval - this.minInterval + 1)
          ) + this.minInterval;
        this.logger.debug(`等待 ${waitTime} 秒后进行下次检查...`);
        await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
      } catch (error) {
        this.logger.error(`运行异常: ${error.message}`);
        // 异常后等待较长时间再重试
        await new Promise((resolve) => setTimeout(resolve, 60000));
      }
    }
  }
}

/**
 * 主函数
 */
async function main() {
  try {
    const monitor = new RSSMonitor();
    await monitor.run();
  } catch (error) {
    console.error(`程序启动失败: ${error.message}`);
    process.exit(1);
  }
}

// 运行主函数
main();

环境变量文件

vim .env

修改和替换以下内容再粘贴和保存:

# RSS 监控器环境变量配置文件
# 复制此文件为 .env 并填入实际值

# Gmail SMTP 配置,先启用账号双重认证,再访问生成应用密码:
# https://myaccount.google.com/apppasswords
GMAIL_USER=your@gmail.com
GMAIL_APP_PASSWORD=pass word pass word
NOTIFICATION_EMAIL=recipient@yourdomain.com

# 原有配置保持不变
KEYWORDS=闪购,关键词用逗号,分隔
CHECK_MIN_INTERVAL=20
CHECK_MAX_INTERVAL=40

# RSS 源地址(可选,默认使用 NodeSeek)
RSS_URL=https://rss.nodeseek.com/

安装 pm2

npm install -g pm2
pm2 startup

将 PM2 配置为在系统重启时自动启动

启动应用

pm2 start main.js --name "ns-rss-keywords-smtp"
pm2 save

保存当前的进程列表

查看状态和日志

pm2 list
pm2 logs ns-rss-keywords-smtp