mirror of
https://github.com/davegallant/rfd-notify.git
synced 2025-08-06 08:43:39 +00:00
Add initial (#1)
This commit is contained in:
71
.github/workflows/ci.yaml
vendored
Normal file
71
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Based on https://github.com/actions-rs/meta/blob/master/recipes/quickstart.md
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Install stable toolchain
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
profile: minimal
|
||||||
|
toolchain: stable
|
||||||
|
override: true
|
||||||
|
|
||||||
|
- name: Run cargo check
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: check
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Test Suite
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Install stable toolchain
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
profile: minimal
|
||||||
|
toolchain: stable
|
||||||
|
override: true
|
||||||
|
|
||||||
|
- name: Run cargo test
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: test
|
||||||
|
|
||||||
|
lints:
|
||||||
|
name: Lints
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout sources
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Install stable toolchain
|
||||||
|
uses: actions-rs/toolchain@v1
|
||||||
|
with:
|
||||||
|
profile: minimal
|
||||||
|
toolchain: stable
|
||||||
|
override: true
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Run cargo fmt
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: fmt
|
||||||
|
args: --all -- --check
|
||||||
|
|
||||||
|
- name: Run cargo clippy
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: clippy
|
||||||
|
args: -- -D warnings
|
38
.github/workflows/release.yaml
vendored
Normal file
38
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish for ${{ matrix.os }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
artifact_name: rfd-notify
|
||||||
|
asset_name: rfd-notify-linux-amd64
|
||||||
|
- os: windows-latest
|
||||||
|
artifact_name: rfd-notify.exe
|
||||||
|
asset_name: rfd-notify-windows-amd64
|
||||||
|
- os: macos-latest
|
||||||
|
artifact_name: rfd-notify
|
||||||
|
asset_name: rfd-notify-macos-amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: hecrj/setup-rust-action@v1
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --locked
|
||||||
|
- name: Upload binaries to release
|
||||||
|
uses: svenstaro/upload-release-action@v1-release
|
||||||
|
with:
|
||||||
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
file: target/release/${{ matrix.artifact_name }}
|
||||||
|
asset_name: ${{ matrix.asset_name }}
|
||||||
|
tag: ${{ github.ref }}
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
1675
Cargo.lock
generated
Normal file
1675
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "rfd-notify"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["Dave Gallant <davegallant@gmail.com>"]
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sendgrid = { version = "0.11.3", features = ["rustls"] }
|
||||||
|
toml = "0.5.6"
|
||||||
|
serde_derive = "1.0.112"
|
||||||
|
serde = "1.0.112"
|
||||||
|
reqwest = { version = "0.10", features = ["json"] }
|
||||||
|
tokio = { version = "0.2", features = ["full"] }
|
||||||
|
serde_json = "1.0.55"
|
||||||
|
log = "0.4"
|
||||||
|
pretty_env_logger = "0.4.0"
|
||||||
|
regex = "1.3.9"
|
||||||
|
rust-crypto = "^0.2"
|
||||||
|
sled = "0.31.0"
|
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <http://unlicense.org>
|
28
README.md
28
README.md
@@ -1 +1,29 @@
|
|||||||
# rfd-notify
|
# rfd-notify
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
This tool looks for regular expressions from [RedFlagDeals.com forums](https://forums.redflagdeals.com/hot-deals-f9/) and will send emails based on matches.
|
||||||
|
|
||||||
|
|
||||||
|
## requirements
|
||||||
|
|
||||||
|
- a free [SendGrid API key](https://sendgrid.com/pricing/)
|
||||||
|
|
||||||
|
## use
|
||||||
|
|
||||||
|
Declare a configuration. An example can found in [config.toml](./examples/config.toml)
|
||||||
|
|
||||||
|
```shell
|
||||||
|
rfd-notify ./examples/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## cross compile
|
||||||
|
|
||||||
|
I had motivations to run this on a Raspberry Pi Zero:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
alias rust-musl-builder='docker run --rm -it -v "$(pwd)":/home/rust/src messense/rust-musl-cross:arm-musleabihf'
|
||||||
|
rust-musl-builder cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The above can be substituted for [other architectures](https://github.com/messense/rust-musl-cross#prebuilt-images).
|
||||||
|
11
examples/config.toml
Normal file
11
examples/config.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
expressions = [
|
||||||
|
"amazon",
|
||||||
|
"costco",
|
||||||
|
"rx.?5[6789]0",
|
||||||
|
"starbucks"
|
||||||
|
]
|
||||||
|
|
||||||
|
[sendgrid]
|
||||||
|
api_key = "<SENDGRID-API-KEY>"
|
||||||
|
mail_to = "<YOUR-EMAIL>@gmail.com"
|
||||||
|
mail_from = "Notify <notify@rfdharvester.net>"
|
40
src/config.rs
Normal file
40
src/config.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
extern crate toml;
|
||||||
|
use serde_derive::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_config() {
|
||||||
|
let file = "./examples/config.toml";
|
||||||
|
parse(&file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub expressions: Vec<String>,
|
||||||
|
pub sendgrid: Sendgrid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct Sendgrid {
|
||||||
|
pub mail_from: String,
|
||||||
|
pub mail_to: String,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(filename: &str) -> Config {
|
||||||
|
let contents = fs::read_to_string(filename)
|
||||||
|
.unwrap_or_else(|e| panic!("Unable to read configuration file '{}'. {}", filename, e));
|
||||||
|
let config: Config = toml::from_str(&contents).unwrap_or_else(|e| {
|
||||||
|
panic!(
|
||||||
|
"Unable to parse configuration with contents: {}. {}",
|
||||||
|
contents, e
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
config
|
||||||
|
}
|
23
src/db.rs
Normal file
23
src/db.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pub fn hash_exists(hash: &str) -> bool {
|
||||||
|
let tree = sled::open("./deals_db").expect("open");
|
||||||
|
let result = tree.get(hash);
|
||||||
|
if result.is_err() {
|
||||||
|
error!("{:?}", &result);
|
||||||
|
}
|
||||||
|
if result == Ok(None) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(hash: &str) {
|
||||||
|
let tree = sled::open("./deals_db").expect("open");
|
||||||
|
let result = tree.insert(&hash, "");
|
||||||
|
if result.is_err() {
|
||||||
|
error!("{:?}", &result);
|
||||||
|
}
|
||||||
|
let result = tree.flush();
|
||||||
|
if result.is_err() {
|
||||||
|
error!("{:?}", &result);
|
||||||
|
}
|
||||||
|
}
|
46
src/mail.rs
Normal file
46
src/mail.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::rfd::Posts;
|
||||||
|
use crate::rfd::Topic;
|
||||||
|
use sendgrid::SGClient;
|
||||||
|
use sendgrid::{Destination, Mail};
|
||||||
|
|
||||||
|
const RFD_FORUMS_BASE_URL: &str = "https://forums.redflagdeals.com";
|
||||||
|
|
||||||
|
pub fn send(topic: &Topic, posts: &Posts, config: &Config) {
|
||||||
|
let api_key = &config.sendgrid.api_key;
|
||||||
|
let sg = SGClient::new(api_key.to_string());
|
||||||
|
|
||||||
|
let html_message = format!(
|
||||||
|
"\
|
||||||
|
<b>First Posted:</b> {}
|
||||||
|
<br>
|
||||||
|
<b>DEALER:</b> {:?}
|
||||||
|
<br>
|
||||||
|
<b>DEAL:</b> {:?}
|
||||||
|
<br>
|
||||||
|
<b>POST:</b> {}\
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<b>Body:</b> {}
|
||||||
|
",
|
||||||
|
topic.post_time,
|
||||||
|
topic.offer.dealer_name,
|
||||||
|
topic.offer.url,
|
||||||
|
format!("{}/{}", RFD_FORUMS_BASE_URL, topic.web_path),
|
||||||
|
posts.posts[0].body,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mail_info = Mail::new()
|
||||||
|
.add_to(Destination {
|
||||||
|
address: &config.sendgrid.mail_to,
|
||||||
|
name: "you",
|
||||||
|
})
|
||||||
|
.add_from(&config.sendgrid.mail_from)
|
||||||
|
.add_subject(&topic.title)
|
||||||
|
.add_html(&html_message);
|
||||||
|
|
||||||
|
match sg.send(mail_info) {
|
||||||
|
Err(err) => println!("Error: {}", err),
|
||||||
|
Ok(body) => println!("Response: {}", body),
|
||||||
|
};
|
||||||
|
}
|
38
src/main.rs
Normal file
38
src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
extern crate pretty_env_logger;
|
||||||
|
#[macro_use]
|
||||||
|
extern crate log;
|
||||||
|
extern crate crypto;
|
||||||
|
mod config;
|
||||||
|
mod db;
|
||||||
|
mod mail;
|
||||||
|
mod rfd;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
fn help() {
|
||||||
|
println!(
|
||||||
|
"usage:\n
|
||||||
|
rfd-notify <config-toml>
|
||||||
|
Specify the filepath of the config."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
pretty_env_logger::init();
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
|
match args.len() {
|
||||||
|
2 => {
|
||||||
|
let config_path = &args[1];
|
||||||
|
let config = config::parse(config_path);
|
||||||
|
debug!("{:?}\n", config);
|
||||||
|
let hot_deals = rfd::get_hot_deals().map_err(|err| error!("{:?}", err)).ok();
|
||||||
|
let parsed_deals = rfd::parse_hot_deals(&hot_deals.unwrap());
|
||||||
|
rfd::match_deals(parsed_deals, config)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
help();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Complete")
|
||||||
|
}
|
118
src/rfd.rs
Normal file
118
src/rfd.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::db;
|
||||||
|
use crate::mail;
|
||||||
|
use crypto::digest::Digest;
|
||||||
|
use crypto::sha2::Sha256;
|
||||||
|
use regex::RegexBuilder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Deals {
|
||||||
|
topics: Vec<Topic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Posts {
|
||||||
|
pub posts: Vec<Post>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Post {
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Topic {
|
||||||
|
#[serde(rename = "topic_id")]
|
||||||
|
pub id: u32,
|
||||||
|
pub title: String,
|
||||||
|
pub post_time: String,
|
||||||
|
pub web_path: String,
|
||||||
|
pub offer: Offer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Offer {
|
||||||
|
pub dealer_name: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn get_hot_deals() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let resp = reqwest::get("https://forums.redflagdeals.com/api/topics?forum_id=9&per_page=40")
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
pub async fn get_topic(topic_id: u32) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
let resp = reqwest::get(&format!(
|
||||||
|
"https://forums.redflagdeals.com/api/topics/{}/posts?per_page=1&page=1",
|
||||||
|
topic_id
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await?;
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_hot_deals(response: &str) -> Deals {
|
||||||
|
serde_json::from_str(&response).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_posts(response: String) -> Posts {
|
||||||
|
serde_json::from_str(&response).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_deal(topic: &Topic) -> String {
|
||||||
|
let digest = format!("{}{}{}", topic.web_path, topic.title, topic.post_time);
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.input_str(&digest);
|
||||||
|
hasher.result_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_deals(deals: Deals, config: Config) {
|
||||||
|
for topic in deals.topics.iter() {
|
||||||
|
for expression in config.expressions.iter() {
|
||||||
|
let mut found_match = false;
|
||||||
|
let re = RegexBuilder::new(expression)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|e| panic!("Invalid regex: {}. {}", expression, e));
|
||||||
|
if re.is_match(&topic.title) {
|
||||||
|
found_match = true;
|
||||||
|
debug!(
|
||||||
|
"Expression '{}' matched title: {}",
|
||||||
|
expression, &topic.title
|
||||||
|
)
|
||||||
|
} else if topic.offer.dealer_name.is_some() {
|
||||||
|
let dealer_name = topic.offer.dealer_name.as_ref().unwrap();
|
||||||
|
if re.is_match(&dealer_name) {
|
||||||
|
found_match = true;
|
||||||
|
debug!(
|
||||||
|
"Expression '{}' matched dealer: {}",
|
||||||
|
expression, &topic.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found_match {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let deal_hash = hash_deal(topic);
|
||||||
|
if db::hash_exists(&deal_hash) {
|
||||||
|
debug!("deal hash '{}' already exists", &deal_hash);
|
||||||
|
} else {
|
||||||
|
let posts = parse_posts(
|
||||||
|
get_topic(topic.id)
|
||||||
|
.map_err(|err| error!("{:?}", err))
|
||||||
|
.ok()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
db::insert(&deal_hash);
|
||||||
|
mail::send(topic, &posts, &config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user