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