1use std::sync::{OnceLock as SyncOnceCell, RwLock};
2
3use chrono::Utc;
4use chrono_tz::Asia::Shanghai;
5use rand::{Rng, distr::Alphanumeric};
6use reqwest::Response;
7use ring::{
8 digest::Digest,
9 hmac::{self, Key},
10};
11use serde::{Serialize, de::DeserializeOwned};
12use serde_json::{Map, Value};
13use tokio::sync::OnceCell;
14use url::{Url, form_urlencoded};
15
16use super::Config;
17use crate::{CiweimaoClient, Error, HTTPClient, NovelDB};
18
19impl CiweimaoClient {
20 const APP_NAME: &'static str = "ciweimao";
21 const HOST: &'static str = "https://app.hbooker.com";
22
23 pub(crate) const OK: &'static str = "100000";
24 pub(crate) const LOGIN_EXPIRED: &'static str = "200100";
25 pub(crate) const NOT_FOUND: &'static str = "320001";
26 pub(crate) const ALREADY_SIGNED_IN: &'static str = "340001";
27
28 pub(crate) const APP_VERSION: &'static str = "2.9.336";
29 pub(crate) const DEVICE_TOKEN: &'static str = "ciweimao_";
30
31 const USER_AGENT: &'static str =
32 "Android com.kuangxiangciweimao.novel 2.9.336,google, sdk_gphone64_arm64, 31, 12";
33 const USER_AGENT_RSS: &'static str =
34 "Dalvik/2.1.0 (Linux; U; Android 12; sdk_gphone64_arm64 Build/SE1A.220203.002.A1)";
35
36 const AES_KEY: &'static str = "zG2nSeEfSHfvTCHy5LCcqtBbQehKNLXn";
37 const HMAC_KEY: &'static str = "a90f3731745f1c30ee77cb13fc00005a";
38 const SIGNATURES: &'static str =
39 const_format::concatcp!(CiweimaoClient::HMAC_KEY, "CkMxWNB666");
40
41 pub async fn new() -> Result<Self, Error> {
43 let config: Option<Config> = crate::load_config_file(CiweimaoClient::APP_NAME)?;
44
45 Ok(Self {
46 proxy: None,
47 no_proxy: false,
48 cert_path: None,
49 client: OnceCell::new(),
50 client_rss: OnceCell::new(),
51 db: OnceCell::new(),
52 config: RwLock::new(config),
53 })
54 }
55
56 #[must_use]
57 pub(crate) fn try_account(&self) -> String {
58 if self.has_token() {
59 self.config
60 .read()
61 .unwrap()
62 .as_ref()
63 .unwrap()
64 .account
65 .to_string()
66 } else {
67 String::default()
68 }
69 }
70
71 #[must_use]
72 pub(crate) fn try_login_token(&self) -> String {
73 if self.has_token() {
74 self.config
75 .read()
76 .unwrap()
77 .as_ref()
78 .unwrap()
79 .login_token
80 .to_string()
81 } else {
82 String::default()
83 }
84 }
85
86 #[must_use]
87 pub(crate) fn has_token(&self) -> bool {
88 self.config.read().unwrap().is_some()
89 }
90
91 pub(crate) fn save_token(&self, config: Config) {
92 *self.config.write().unwrap() = Some(config);
93 }
94
95 pub(crate) async fn db(&self) -> Result<&NovelDB, Error> {
96 self.db
97 .get_or_try_init(|| async { NovelDB::new(CiweimaoClient::APP_NAME).await })
98 .await
99 }
100
101 pub(crate) async fn client(&self) -> Result<&HTTPClient, Error> {
102 self.client
103 .get_or_try_init(|| async {
104 HTTPClient::builder(CiweimaoClient::APP_NAME)
105 .user_agent(CiweimaoClient::USER_AGENT)
106 .allow_compress(false)
108 .proxy(self.proxy.clone())
109 .no_proxy(self.no_proxy)
110 .cert(self.cert_path.clone())
111 .build()
112 .await
113 })
114 .await
115 }
116
117 async fn client_rss(&self) -> Result<&HTTPClient, Error> {
118 self.client_rss
119 .get_or_try_init(|| async {
120 HTTPClient::builder(CiweimaoClient::APP_NAME)
121 .user_agent(CiweimaoClient::USER_AGENT_RSS)
122 .proxy(self.proxy.clone())
123 .no_proxy(self.no_proxy)
124 .cert(self.cert_path.clone())
125 .build()
126 .await
127 })
128 .await
129 }
130
131 pub(crate) async fn get_query<T, E>(&self, url: T, query: E) -> Result<Response, Error>
132 where
133 T: AsRef<str>,
134 E: Serialize,
135 {
136 let response = self
137 .client()
138 .await?
139 .get(CiweimaoClient::HOST.to_string() + url.as_ref())
140 .query(&query)
141 .send()
142 .await?;
143 crate::check_status(
144 response.status(),
145 format!("HTTP request failed: `{}`", url.as_ref()),
146 )?;
147
148 Ok(response)
149 }
150
151 pub(crate) async fn post<T, E, R>(&self, url: T, form: E) -> Result<R, Error>
152 where
153 T: AsRef<str>,
154 E: Serialize,
155 R: DeserializeOwned,
156 {
157 let mut count = 0;
158
159 let response = loop {
160 let response = self
161 .client()
162 .await?
163 .post(CiweimaoClient::HOST.to_string() + url.as_ref())
164 .form(&self.append_param(&form)?)
165 .send()
166 .await;
167
168 if let Ok(response) = response {
169 break response;
170 } else {
171 tracing::info!(
172 "HTTP request failed: `{}`, retry, number of times: `{}`",
173 response.as_ref().unwrap_err(),
174 count + 1
175 );
176
177 count += 1;
178 if count > 3 {
179 response?;
180 }
181 }
182 };
183
184 crate::check_status(
185 response.status(),
186 format!("HTTP request failed: `{}`", url.as_ref()),
187 )?;
188
189 let bytes = response.bytes().await?;
190 let bytes = crate::aes_256_cbc_no_iv_base64_decrypt(CiweimaoClient::get_aes_key(), &bytes)?;
191
192 let str = simdutf8::basic::from_utf8(&bytes)?;
193 Ok(serde_json::from_str(str)?)
194 }
195
196 pub(crate) async fn get_rss(&self, url: &Url) -> Result<Response, Error> {
197 let response = self.client_rss().await?.get(url.clone()).send().await?;
198 crate::check_status(response.status(), format!("HTTP request failed: `{url}`"))?;
199
200 Ok(response)
201 }
202
203 fn append_param<T>(&self, query: T) -> Result<Map<String, Value>, Error>
204 where
205 T: Serialize,
206 {
207 let mut value = serde_json::to_value(query)?;
208 let object = value.as_object_mut().unwrap();
209
210 object.insert(
211 String::from("app_version"),
212 serde_json::json!(CiweimaoClient::APP_VERSION),
213 );
214 object.insert(
215 String::from("device_token"),
216 serde_json::json!(CiweimaoClient::DEVICE_TOKEN),
217 );
218
219 let rand_str = CiweimaoClient::get_rand_str();
220 let p = self.hmac(&rand_str);
221 object.insert(String::from("rand_str"), serde_json::json!(rand_str));
222 object.insert(String::from("p"), serde_json::json!(p));
223
224 if self.has_token() {
225 object.insert(
226 String::from("account"),
227 serde_json::json!(self.try_account()),
228 );
229 object.insert(
230 String::from("login_token"),
231 serde_json::json!(self.try_login_token()),
232 );
233 }
234
235 Ok(value.as_object().unwrap().clone())
236 }
237
238 #[must_use]
239 fn get_aes_key() -> &'static [u8] {
240 static AES_KEY: SyncOnceCell<Digest> = SyncOnceCell::new();
241 AES_KEY
242 .get_or_init(|| crate::sha256(CiweimaoClient::AES_KEY.as_bytes()))
243 .as_ref()
244 }
245
246 #[must_use]
247 fn get_hmac_key() -> &'static Key {
248 static HMAC_KEY: SyncOnceCell<Key> = SyncOnceCell::new();
249 HMAC_KEY
250 .get_or_init(|| hmac::Key::new(hmac::HMAC_SHA256, CiweimaoClient::HMAC_KEY.as_bytes()))
251 }
252
253 fn get_rand_str() -> String {
254 let utc_now = Utc::now();
255 let shanghai_now = utc_now.with_timezone(&Shanghai);
256
257 let rand_str: String = rand::rng()
258 .sample_iter(&Alphanumeric)
259 .take(12)
260 .map(|c| char::from(c).to_lowercase().to_string())
261 .collect();
262
263 format!("{}{}", shanghai_now.format("%M%S"), rand_str)
264 }
265
266 fn hmac(&self, rand_str: &str) -> String {
267 let msg: String = form_urlencoded::Serializer::new(String::new())
268 .append_pair("account", &self.try_account())
269 .append_pair("app_version", CiweimaoClient::APP_VERSION)
270 .append_pair("rand_str", rand_str)
271 .append_pair("signatures", CiweimaoClient::SIGNATURES)
272 .finish();
273
274 let tag = hmac::sign(CiweimaoClient::get_hmac_key(), msg.as_bytes());
275 base64_simd::STANDARD.encode_to_string(tag.as_ref())
276 }
277
278 pub(crate) fn do_shutdown(&self) -> Result<(), Error> {
279 if self.has_token() {
280 crate::save_config_file(
281 CiweimaoClient::APP_NAME,
282 self.config.write().unwrap().take(),
283 )?;
284 } else {
285 tracing::info!("No data can be saved to the configuration file");
286 }
287
288 Ok(())
289 }
290}
291
292impl Drop for CiweimaoClient {
293 fn drop(&mut self) {
294 if let Err(err) = self.do_shutdown() {
295 tracing::error!("Fail to save config file: `{err}`");
296 }
297 }
298}
299
300pub(crate) fn check_response_success(code: String, tip: Option<String>) -> Result<(), Error> {
301 if code != CiweimaoClient::OK {
302 Err(Error::NovelApi(format!(
303 "{} request failed, code: `{code}`, msg: `{}`",
304 CiweimaoClient::APP_NAME,
305 tip.unwrap().trim()
306 )))
307 } else {
308 Ok(())
309 }
310}
311
312pub(crate) fn check_already_signed_in(code: &str) -> bool {
313 code == CiweimaoClient::ALREADY_SIGNED_IN
314}