Add support for image uploads and link filtering in RSS feeds
This commit is contained in:
parent
a3c71744dc
commit
c9f8f8f276
9 changed files with 94 additions and 8 deletions
|
|
@ -1 +1,3 @@
|
||||||
DISCORD_TOKEN=your_discord_bot_token_here
|
DISCORD_TOKEN=your_discord_bot_token_here
|
||||||
|
RSS_USER_AGENT=your_custom_user_agent_here
|
||||||
|
INTERVAL_MINUTES=5
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1033,6 +1033,7 @@ dependencies = [
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"feed-rs",
|
"feed-rs",
|
||||||
"htmd",
|
"htmd",
|
||||||
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,4 @@ anyhow = "1.0"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
sled = "0.34"
|
sled = "0.34"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
|
regex = "1"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Discord RSS monitor written in Rust.
|
||||||
1. Create a `.env` file with:
|
1. Create a `.env` file with:
|
||||||
```
|
```
|
||||||
DISCORD_TOKEN=your_token
|
DISCORD_TOKEN=your_token
|
||||||
INTERVAL_MINUTES=5
|
INTERVAL_MINUTES=15
|
||||||
```
|
```
|
||||||
2. Configure `rss.toml`.
|
2. Configure `rss.toml`.
|
||||||
3. Run with `cargo run`.
|
3. Run with `cargo run`.
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/usr/app/data
|
- ./data:/usr/app/data
|
||||||
- ./rss.toml:/usr/app/rss.toml
|
- ./rss.toml:/usr/app/rss.toml
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,17 @@ link = "link"
|
||||||
content = "description"
|
content = "description"
|
||||||
author = "dc:creator"
|
author = "dc:creator"
|
||||||
pubDate = "pubDate"
|
pubDate = "pubDate"
|
||||||
|
|
||||||
|
[quasarzone]
|
||||||
|
channel = "1448338163864174867"
|
||||||
|
tag = "1461101612960583753"
|
||||||
|
emoji = "🪐"
|
||||||
|
rss = "https://quasarzone.com/rss.xml"
|
||||||
|
link_filter = ["^https?:\/\/[^\/]+\/bbs\/qn_game(\/|$)", "^https?:\/\/[^\/]+\/bbs\/qn_partner(\/|$)"]
|
||||||
|
upload_image = true
|
||||||
|
|
||||||
|
[quasarzone.setup]
|
||||||
|
title = "title"
|
||||||
|
link = "link"
|
||||||
|
content = "description"
|
||||||
|
author = "dc:creator"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ pub struct RssConfig {
|
||||||
pub emoji: String,
|
pub emoji: String,
|
||||||
pub rss: String,
|
pub rss: String,
|
||||||
pub category_filter: Option<Vec<String>>,
|
pub category_filter: Option<Vec<String>>,
|
||||||
|
pub link_filter: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub upload_image: bool,
|
||||||
pub setup: Option<SetupConfig>,
|
pub setup: Option<SetupConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
34
src/main.rs
34
src/main.rs
|
|
@ -77,6 +77,16 @@ async fn main() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn download_image(url: &str) -> Result<Vec<u8>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent(std::env::var("RSS_USER_AGENT").unwrap_or_else(|_| "NekoRSS/1.0".to_string()))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let response = client.get(url).send().await?;
|
||||||
|
let data = response.bytes().await?.to_vec();
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
async fn check_feed(
|
async fn check_feed(
|
||||||
name: &str,
|
name: &str,
|
||||||
config: &crate::config::RssConfig,
|
config: &crate::config::RssConfig,
|
||||||
|
|
@ -116,10 +126,32 @@ async fn check_feed(
|
||||||
let title = rss::get_field_value(&item, config, "title").unwrap_or_else(|| "Untitled".to_string());
|
let title = rss::get_field_value(&item, config, "title").unwrap_or_else(|| "Untitled".to_string());
|
||||||
let tag_id = config.tag.parse::<u64>()?;
|
let tag_id = config.tag.parse::<u64>()?;
|
||||||
|
|
||||||
|
// Extract image if needed
|
||||||
|
let image_url = if config.upload_image {
|
||||||
|
rss::extract_image_url(&item)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create message with image if available
|
||||||
|
let mut message_builder = serenity::builder::CreateMessage::new().content(content);
|
||||||
|
if let Some(img_url) = image_url {
|
||||||
|
match download_image(&img_url).await {
|
||||||
|
Ok(image_data) => {
|
||||||
|
message_builder = message_builder.add_file(
|
||||||
|
serenity::all::CreateAttachment::bytes(image_data, "image.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[{}] Failed to download image: {}", name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Post to forum
|
// Post to forum
|
||||||
let post = serenity::builder::CreateForumPost::new(
|
let post = serenity::builder::CreateForumPost::new(
|
||||||
title,
|
title,
|
||||||
serenity::builder::CreateMessage::new().content(content)
|
message_builder
|
||||||
).add_applied_tag(serenity::all::ForumTagId::new(tag_id));
|
).add_applied_tag(serenity::all::ForumTagId::new(tag_id));
|
||||||
|
|
||||||
if let Err(e) = channel.create_forum_post(&http, post).await {
|
if let Err(e) = channel.create_forum_post(&http, post).await {
|
||||||
|
|
|
||||||
44
src/rss.rs
44
src/rss.rs
|
|
@ -2,10 +2,11 @@ use crate::config::RssConfig;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use htmd::HtmlToMarkdown;
|
use htmd::HtmlToMarkdown;
|
||||||
use feed_rs::model::{Feed, Entry};
|
use feed_rs::model::{Feed, Entry};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
pub async fn fetch_feed(url: &str) -> Result<Feed> {
|
pub async fn fetch_feed(url: &str) -> Result<Feed> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.user_agent("NekoRSS/1.0 (+abuse@imnya.ng)")
|
.user_agent(std::env::var("RSS_USER_AGENT").unwrap_or_else(|_| "NekoRSS/1.0 (+https://github.com/imnyang/memos-rss)".to_string()))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let resp = client.get(url).send().await?;
|
let resp = client.get(url).send().await?;
|
||||||
|
|
@ -21,6 +22,33 @@ pub async fn fetch_feed(url: &str) -> Result<Feed> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn should_include_by_link(link: Option<&String>, link_filters: Option<&Vec<String>>) -> bool {
|
||||||
|
match (link, link_filters) {
|
||||||
|
(Some(link_url), Some(filters)) => {
|
||||||
|
// 모든 필터 중 하나라도 매치되면 포함
|
||||||
|
filters.iter().any(|filter| {
|
||||||
|
if let Ok(regex) = Regex::new(filter) {
|
||||||
|
regex.is_match(link_url)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(_, None) => true, // 필터가 없으면 모두 포함
|
||||||
|
(None, Some(_)) => false, // 필터가 있는데 link가 없으면 제외
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_image_url(item: &Entry) -> Option<String> {
|
||||||
|
// 첫 번째로 media:content에서 이미지 찾기
|
||||||
|
item.media
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.content.iter().any(|c| c.medium.as_deref() == Some("image")))
|
||||||
|
.and_then(|m| m.content.first())
|
||||||
|
.and_then(|c| c.url.clone())
|
||||||
|
.map(|url| url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_field_value(
|
pub fn get_field_value(
|
||||||
item: &Entry,
|
item: &Entry,
|
||||||
config: &RssConfig,
|
config: &RssConfig,
|
||||||
|
|
@ -38,8 +66,6 @@ pub fn get_field_value(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(_p) = path {
|
if let Some(_p) = path {
|
||||||
// Path-based resolution is harder with feed-rs because it's already abstracted.
|
|
||||||
// We'll map some known paths to feed-rs fields.
|
|
||||||
match _p.as_str() {
|
match _p.as_str() {
|
||||||
"title" => item.title.as_ref().map(|t| t.content.clone()),
|
"title" => item.title.as_ref().map(|t| t.content.clone()),
|
||||||
"link" => item.links.first().map(|l| l.href.clone()),
|
"link" => item.links.first().map(|l| l.href.clone()),
|
||||||
|
|
@ -93,9 +119,15 @@ pub fn build_content(config: &RssConfig, item: &Entry) -> String {
|
||||||
parts.push(format!("\n{}\n", markdown.trim()));
|
parts.push(format!("\n{}\n", markdown.trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(pd) = pub_date {
|
let timestamp = pub_date
|
||||||
parts.push(format!("\n-# 🕐 <t:{}:f>", pd.timestamp()));
|
.map(|pd| pd.timestamp())
|
||||||
}
|
.unwrap_or_else(|| {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64
|
||||||
|
});
|
||||||
|
parts.push(format!("\n-# 🕐 <t:{}:f>", timestamp));
|
||||||
|
|
||||||
let joined = parts.join("\n");
|
let joined = parts.join("\n");
|
||||||
if joined.is_empty() {
|
if joined.is_empty() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue