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(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 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 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 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}