Browse Source

Initial commit

master
Stephen 3 weeks ago
commit
4804967c95
9 changed files with 1349 additions and 0 deletions
  1. +1
    -0
      .gitignore
  2. +1079
    -0
      Cargo.lock
  3. +17
    -0
      Cargo.toml
  4. +5
    -0
      README.md
  5. +2
    -0
      example.toml
  6. +2
    -0
      rustfmt.toml
  7. +45
    -0
      src/config.rs
  8. +123
    -0
      src/homedepot.rs
  9. +75
    -0
      src/main.rs

+ 1
- 0
.gitignore View File

@ -0,0 +1 @@
/target

+ 1079
- 0
Cargo.lock
File diff suppressed because it is too large
View File


+ 17
- 0
Cargo.toml View File

@ -0,0 +1,17 @@
[package]
name = "homedepot-tui"
version = "0.1.0"
authors = ["Stephen <webmaster@scd31.com>"]
edition = "2018"
license = "MIT"
description = "A simple terminal frontend for Home Depot."
readme = "README.md"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
reqwest = { version = "0.11.2", features = ["json"] }
tokio = { version = "1.4", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
toml = "0.5.8"
home = "0.5.3"

+ 5
- 0
README.md View File

@ -0,0 +1,5 @@
# homedepot-tui
A (very) simple terminal frontend for Home Depot's website. Kind of brittle, but works nice enough for my purposes.
To start, rename `example.toml` to `homedepot.toml`, or copy it to `~/.homedepot.toml`. Modify it as needed.

+ 2
- 0
example.toml View File

@ -0,0 +1,2 @@
store_id = 1234
country_code = "CA"

+ 2
- 0
rustfmt.toml View File

@ -0,0 +1,2 @@
tab_spaces = 4
hard_tabs = true

+ 45
- 0
src/config.rs View File

@ -0,0 +1,45 @@
use serde::Deserialize;
use std::fs;
#[derive(Deserialize)]
struct ConfigFile {
store_id: usize,
country_code: String,
}
pub struct Config {
pub store_id: usize,
pub canada: bool,
}
pub fn get_config() -> Config {
let contents = get_conf_file();
let config: ConfigFile = toml::from_str(&contents).expect("Could not parse config file");
if config.country_code.to_lowercase() != "ca" && config.country_code.to_lowercase() != "us" {
panic!("Invalid country code - valid options are CA and US");
}
Config {
store_id: config.store_id,
canada: config.country_code.to_lowercase() == "ca",
}
}
fn get_conf_file() -> String {
let mut filenames = vec!["homedepot.toml".to_string()];
if let Some(path) = home::home_dir() {
let mut path = path;
path.push(".homedepot.toml");
filenames.push(path.to_str().unwrap().to_string());
}
for filename in filenames.iter() {
if let Ok(x) = fs::read_to_string(filename) {
return x;
}
}
panic!("Could not open config file");
}

+ 123
- 0
src/homedepot.rs View File

@ -0,0 +1,123 @@
use reqwest::header;
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DisplayPrice {
pub formatted_value: String,
pub unit_of_measure: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ProductPricing {
pub display_price: DisplayPrice,
}
#[derive(Deserialize, Debug)]
pub struct Product {
pub code: String,
pub name: String,
pub pricing: ProductPricing,
}
#[derive(Deserialize)]
struct SearchResponse {
products: Vec<Product>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AisleBay {
pub store_display_name: String,
pub bay_location: String,
pub aisle_location: String,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct StoreStock {
pub stock_level_status: String,
pub stock_level: usize,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ProductDetailed {
pub product_id: String,
pub aisle_bay: AisleBay,
pub store_stock: StoreStock,
pub optimized_price: ProductPricing,
}
pub struct HomeDepot {
client: reqwest::Client,
canada: bool,
store_id: usize,
}
impl HomeDepot {
pub fn new(store_id: usize, canada: bool) -> Self {
Self {
client: reqwest::Client::new(),
canada,
store_id,
}
}
fn get_tld(&self) -> &'static str {
if self.canada {
"ca"
} else {
"com"
}
}
pub async fn search_by_keyword(&self, q: String) -> Option<Vec<Product>> {
let url = format!(
"https://www.homedepot.{}/api/search/v1/search",
self.get_tld()
);
let resp: SearchResponse = self
.client
.get(url)
// Home depot only accepts this user agent. It doesn't even accept the UA that
// I copied out of my browser
.header(header::USER_AGENT, "My First Rust Client 1.0")
.query(&[
("q", q),
("pageSize", "40".to_string()),
("store", format!("{}", self.store_id)),
])
.send()
.await
.ok()?
.json()
.await
.ok()?;
Some(resp.products)
}
pub async fn product_location_by_code(&self, code: &str) -> Option<ProductDetailed> {
let url = format!(
"https://www.homedepot.{}/api/productsvc/v1/products/{}/store/{}",
self.get_tld(),
code,
self.store_id
);
let resp: ProductDetailed = self
.client
.get(url)
.header(header::USER_AGENT, "My First Rust Client 1.0")
.send()
.await
.ok()?
.json()
.await
.ok()?;
Some(resp)
}
}

+ 75
- 0
src/main.rs View File

@ -0,0 +1,75 @@
mod config;
mod homedepot;
use std::io;
use std::io::Write;
fn read_line() -> String {
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
fn flush() {
io::stdout().flush().expect("Could not flush stdout");
}
#[tokio::main]
async fn main() {
let config = config::get_config();
let hd = homedepot::HomeDepot::new(config.store_id, config.canada);
print!("Search query? ");
flush();
let q = read_line();
let results = match hd.search_by_keyword(q).await {
Some(x) => x,
None => {
eprintln!("Error communicating with server");
return;
}
};
for (i, product) in results.iter().enumerate() {
println!(
"{}. {}\t {}",
i + 1,
product.pricing.display_price.formatted_value,
product.name
);
}
print!("Product number? ");
flush();
let num: usize = loop {
match read_line().parse() {
Ok(x) if x >= 1 && x <= results.len() => break x,
_ => {
print!("Product number? ");
flush();
}
}
};
let product = &results[num - 1];
match hd.product_location_by_code(&product.code).await {
Some(x) => {
println!("{}", product.name);
println!(
"{} / {}",
x.optimized_price.display_price.formatted_value,
x.optimized_price.display_price.unit_of_measure
);
println!(
"{} in stock at {} | Aisle {}, Bay {}",
x.store_stock.stock_level,
x.aisle_bay.store_display_name,
x.aisle_bay.aisle_location,
x.aisle_bay.bay_location
);
}
None => eprintln!("Error communicating with server"),
}
}

Loading…
Cancel
Save