Add initial (#1)

This commit is contained in:
Dave Gallant
2020-06-21 02:20:36 -04:00
committed by GitHub
parent 966478aece
commit d6c5b13f67
13 changed files with 2133 additions and 1 deletions

71
.github/workflows/ci.yaml vendored Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
/target

1675
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
Cargo.toml Normal file
View 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
View 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>

View File

@@ -1 +1,29 @@
# rfd-notify
# rfd-notify
![](https://github.com/davegallant/rfd-notify/workflows/.github/workflows/ci.yml/badge.svg)
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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}
}
}