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