humble_cli/
humble_api.rs

1use crate::models::*;
2use futures_util::future;
3use reqwest::blocking::Client;
4use scraper::Selector;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum ApiError {
9    #[error(transparent)]
10    NetworkError(#[from] reqwest::Error),
11
12    // #[error("cannot parse the response")]
13    #[error(transparent)]
14    DeserializeError(#[from] serde_json::Error),
15
16    #[error("cannot find any data")]
17    BundleNotFound,
18}
19
20pub struct HumbleApi {
21    auth_key: String,
22}
23
24impl HumbleApi {
25    pub fn new(auth_key: &str) -> Self {
26        Self {
27            auth_key: auth_key.to_owned(),
28        }
29    }
30
31    pub fn list_bundle_keys(&self) -> Result<Vec<String>, ApiError> {
32        let client = Client::new();
33
34        let res = client
35            .get("https://www.humblebundle.com/api/v1/user/order")
36            .header(reqwest::header::ACCEPT, "application/json")
37            .header(
38                "cookie".to_owned(),
39                format!("_simpleauth_sess={}", self.auth_key),
40            )
41            .send()?
42            .error_for_status()?;
43
44        let game_keys = res
45            .json::<Vec<GameKey>>()?
46            .into_iter()
47            .map(|g| g.gamekey)
48            .collect();
49
50        Ok(game_keys)
51    }
52
53    pub fn list_bundles(&self) -> Result<Vec<Bundle>, ApiError> {
54        const CHUNK_SIZE: usize = 10;
55
56        let client = reqwest::Client::new();
57        let game_keys = self.list_bundle_keys()?;
58
59        let runtime = tokio::runtime::Builder::new_multi_thread()
60            .enable_all()
61            .build()
62            .expect("cannot build the tokio runtime");
63
64        let futures = game_keys
65            .chunks(CHUNK_SIZE)
66            .map(|keys| self.read_bundles_data(&client, keys));
67
68        // Collect the Vec<Result<_,_>> into Result<Vec<_>, _>. This will automatically stop when an error is seen.
69        // See https://doc.rust-lang.org/rust-by-example/error/iter_result.html#fail-the-entire-operation-with-collect
70        let result: Result<Vec<Vec<Bundle>>, _> = runtime
71            .block_on(future::join_all(futures))
72            .into_iter()
73            .collect();
74
75        let mut bundles: Vec<_> = result?.into_iter().flatten().collect();
76        bundles.sort_by(|a, b| a.created.partial_cmp(&b.created).unwrap());
77        Ok(bundles)
78    }
79
80    async fn read_bundles_data(
81        &self,
82        client: &reqwest::Client,
83        keys: &[String],
84    ) -> Result<Vec<Bundle>, ApiError> {
85        let mut query_params: Vec<_> = keys.iter().map(|key| ("gamekeys", key.as_str())).collect();
86
87        query_params.insert(0, ("all_tpkds", "true"));
88
89        let res = client
90            .get("https://www.humblebundle.com/api/v1/orders")
91            .header(reqwest::header::ACCEPT, "application/json")
92            .header(
93                "cookie".to_owned(),
94                format!("_simpleauth_sess={}", self.auth_key),
95            )
96            .query(&query_params)
97            .send()
98            .await?
99            .error_for_status()?;
100
101        let product_map = res.json::<BundleMap>().await?;
102        Ok(product_map.into_values().collect())
103    }
104
105    pub fn read_bundle(&self, product_key: &str) -> Result<Bundle, ApiError> {
106        let url = format!(
107            "https://www.humblebundle.com/api/v1/order/{}?all_tpkds=true",
108            product_key
109        );
110
111        let client = Client::new();
112        let res = client
113            .get(url)
114            .header(reqwest::header::ACCEPT, "application/json")
115            .header(
116                "cookie".to_owned(),
117                format!("_simpleauth_sess={}", self.auth_key),
118            )
119            .send()?
120            .error_for_status()?;
121
122        res.json::<Bundle>().map_err(|e| e.into())
123    }
124
125    /// Read Bundle Choices for the give month and year.
126    ///
127    /// `when` should be in the `month-year` format. For example: `"january-2023"`.
128    /// Use `"home"` to get the current active data.
129    pub fn read_bundle_choices(&self, when: &str) -> Result<HumbleChoice, ApiError> {
130        let url = format!("https://www.humblebundle.com/membership/{}", when);
131
132        let client = Client::new();
133        let res = client
134            .get(url)
135            .header(
136                "cookie".to_owned(),
137                format!("_simpleauth_sess={}", self.auth_key),
138            )
139            .send()?
140            .error_for_status()?;
141
142        let html = res.text()?;
143        self.parse_bundle_choices(&html)
144    }
145
146    fn parse_bundle_choices(&self, html: &str) -> Result<HumbleChoice, ApiError> {
147        let document = scraper::html::Html::parse_document(html);
148        // One of these two CSS IDs will match. First one is for the active
149        // month, while the second is for previous months.
150        let sel = Selector::parse(
151            "script#webpack-subscriber-hub-data, script#webpack-monthly-product-data",
152        )
153        .unwrap();
154
155        let scripts: Vec<_> = document.select(&sel).collect();
156        if scripts.len() != 1 {
157            return Err(ApiError::BundleNotFound);
158        }
159
160        let script = scripts.get(0).unwrap();
161        let txt = script.inner_html();
162        let obj: HumbleChoice = serde_json::from_str(&txt)?;
163        Ok(obj)
164    }
165}