novel_api/ciweimao/
utils.rs

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    /// Create a ciweimao client
42    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                    // 因为 HTTP response body 是加密的,所以压缩是没有意义的
107                    .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}