在 VPS 上部署 NodeSeek 关键词监控,到匹配到对应的帖子时发送邮件通知。
以下指令直接复制之后在 VPS 上粘贴回车执行即可,如果你不会用 vim 则替换成 nano。
安装 Node.js
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bashexport 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_completionnvm install 22node -v; npm -v初始化项目
mkdir ns-rss-keywords-smtp && cd $_npm init -yvim 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 pm2pm2 startup将 PM2 配置为在系统重启时自动启动
启动应用
pm2 start main.js --name "ns-rss-keywords-smtp"pm2 save保存当前的进程列表
查看状态和日志
pm2 listpm2 logs ns-rss-keywords-smtp