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