rss解析?

This commit is contained in:
Jerry 2025-05-15 17:59:13 +08:00
parent 7942c59a32
commit 4abe36912d
4 changed files with 184 additions and 48 deletions

View File

@ -0,0 +1,104 @@
import configControl from '../lib/config/configControl.js';
import rssTools from '../models/rss/rss.js';
import path from 'path';
import screenshot from '../lib/rss/screenshot.js';
import fs from 'fs';
const rssCache = new Map();
export default class RssPlugin extends plugin {
constructor() {
super({
name: 'crystelf RSS订阅',
dsc: '定时推送rss解析流',
rule: [
{
reg: '^#rss添加(.+)$',
fnc: 'addFeed',
permission: 'master',
},
{
reg: '^#rss移除(\\d+)$',
fnc: 'removeFeed',
permission: 'master',
},
],
task: [
{
name: 'RSS定时推送',
corn: '*/10 * * * *',
fnc: () => this.pushFeeds(),
},
],
});
}
/**
* 添加rss
* @param e
* @returns {Promise<*>}
*/
async addFeed(e) {
const url = e.msg.replace(/^#rss添加/, '').trim();
const feeds = configControl.get('feeds') || [];
const groupId = e.group_id;
const exists = feeds.find((f) => f.url === url);
if (exists) {
if (!exists.targetGroups.includes(groupId)) {
exists.targetGroups.push(groupId);
await configControl.set('feeds', feeds);
return e.reply(`群已添加到该rss订阅中..`, true);
}
return e.reply(`该rss已存在并包含在该群聊..`, true);
}
feeds.push({ url, targetGroup: [groupId], screenshot: true });
await configControl.set('feeds', feeds);
return e.reply(`rss解析流设置成功..`);
}
/**
* 移除rss
* @param e
* @returns {Promise<*>}
*/
async removeFeed(e) {
const index = parseInt(e.msg.replace(/^#rss移除/, '').trim(), 10);
const feeds = configControl.get('feeds') || [];
const groupId = e.group_id;
if (index < 0 || index >= feeds.length) return e.reply('索引无效');
feeds[index].targetGroups = feeds[index].targetGroups.filter((id) => id !== groupId);
await configControl.set('feeds', feeds);
return e.reply('群已移除该订阅');
}
/**
* 检查rss更新
* @param e
* @returns {Promise<void>}
*/
async pushFeeds(e) {
const feeds = configControl.get('feeds') || [];
for (const feed of feeds) {
const latest = await rssTools.fetchFeed(feed.url);
if (!latest || latest.length) continue;
const cacheKey = feed.url;
const lastId = rssCache.get(cacheKey);
const newItems = lastId ? latest.filter((i) => i.link !== lastId) : latest;
if (newItems.length) rssCache.set(cacheKey, newItems[0].link);
for (const groupId of feed.targetGroups) {
const post = newItems[0];
const tempPath = path.join(process.cwd(), 'data', `rss-${Date.now()}.png`);
if (feed.screenshot) {
await screenshot.generateScreenshot(post, tempPath);
Bot.pickGroup(groupId)?.sendMsg([segment.image(tempPath)]);
fs.unlinkSync(tempPath);
} else {
Bot.pickGroup(groupId)?.sendMsg(`[RSS推送]\n${post.title}\n${post.link}`);
}
}
}
}
}

View File

@ -1,43 +1,37 @@
<!-- TODO 美观html -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<style> <style>
body { body {
font-family: '微软雅黑', sans-serif; font-family: "Helvetica Neue", sans-serif;
background: #f9f9f9; margin: 30px;
padding: 40px; background: #f5f7fa;
margin: 0;
}
.card {
max-width: 800px;
margin: auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
padding: 30px;
}
h1 {
font-size: 22px;
color: #333; color: #333;
margin-bottom: 20px;
} }
p { .container {
font-size: 16px; background: white;
color: #666; padding: 20px;
} border-radius: 8px;
a { box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-decoration: none; max-width: 760px;
color: #1e90ff; margin: auto;
} }
h1 { margin: 0 0 10px; }
.meta { color: #666; font-size: 14px; margin-bottom: 15px; }
.content { font-size: 16px; line-height: 1.6; }
.footer { margin-top: 20px; font-size: 13px; color: #999; }
img { max-width: 100%; border-radius: 4px; }
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="container">
<h1>{{title}}</h1> <h1>{{title}}</h1>
<p>{{description}}</p> <div class="meta">作者:{{author}} | 时间:{{date}}</div>
<p><a href="{{link}}">{{link}}</a></p> <div class="content">{{content}}</div>
<div class="footer">
来自 <a href="{{link}}" target="_blank">{{feedTitle}}</a>
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -4,30 +4,29 @@ import puppeteer from 'puppeteer';
const screenshot = { const screenshot = {
/** /**
* 调用浏览器截图 * rss网页截图
* @param title 标题 * @param feedItem 对象
* @param link 链接 * @param savePath 保存路径
* @param description 描述 * @returns {Promise<*>}
* @returns {Promise<string>} 图片所在目录
*/ */
// TODO 待优化 async generateScreenshot(feedItem, savePath) {
async rssScreenshot(title, link, description = '') { const htmlTemplate = fs.readFileSync(paths.rssHTML, 'utf-8');
const templatePath = paths.rssHTML; const html = htmlTemplate
let html = fs.readFileSync(templatePath, 'utf8'); .replace('{{title}}', feedItem.title)
.replace('{{author}}', feedItem.author)
.replace('{{content}}', feedItem.content)
.replace('{{link}}', feedItem.link)
.replace('{{date}}', new Date(feedItem.date).toLocaleString())
.replace('{{feedTitle}}', feedItem.feedTitle)
.replace('{{image}}', feedItem.image || '');
html = html const browser = await puppeteer.launch({ headers: 'new' });
.replace('{{title}}', title || '')
.replace('{{link}}', link || '')
.replace('{{description}}', description || '');
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage(); const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' }); await page.setContent(html, { waitUntil: 'networkidle0' });
await page.setViewport({ width: 800, height: 600 });
const imagePath = `/tmp/rss_card_${Date.now()}.png`; await page.screenshot({ path: savePath, fullPage: true });
await page.screenshot({ path: imagePath });
await browser.close(); await browser.close();
return imagePath; return savePath;
}, },
}; };

39
models/rss/rss.js Normal file
View File

@ -0,0 +1,39 @@
import Parser from 'rss-parser';
const parser = new Parser();
//去掉不干净的东西
const cleanHTML = (html) => {
return html
.replace(/.*?<\/blockquote>/gs, '')
.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, '');
};
const rssTools = {
/**
* 拉取rss
* 已适配Atom&RSS2.0
* @param url rss地址
* @returns {Promise<{title: *, link: *, content: *, author, date, feedTitle: string, feedLink: string, image}[]|null>}
*/
async fetchFeed(url) {
try {
const feed = await parser.parseURL(url);
return feed.items.map((item) => ({
title: item.title,
link: item.link,
content: cleanHTML(item['content:encoded'] || item.content || item.description || ''),
author: item.creator || item.author || feed.title,
date: item.pubDate || item.isoDate,
feedTitle: feed.title,
feedLink: feed.link,
image: feed.image?.url || '',
}));
} catch (err) {
logger.error(`RSS 拉取失败: ${url}`, err);
return null;
}
},
};
export default rssTools;