Skip to main content

novel_api/ciweimao/
mod.rs

1mod server;
2mod structure;
3mod utils;
4
5use std::io::Cursor;
6use std::path::PathBuf;
7use std::slice;
8use std::sync::RwLock;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use chrono::{Duration, Local, NaiveDateTime, TimeZone};
12use chrono_tz::Asia::Shanghai;
13use hashbrown::HashMap;
14use image::{DynamicImage, ImageReader};
15use scraper::{Html, Selector};
16use serde::{Deserialize, Serialize};
17use tokio::sync::OnceCell;
18use url::Url;
19
20use self::structure::*;
21use crate::{
22    Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
23    FindImageResult, FindTextResult, HTTPClient, LongComment, NovelDB, NovelInfo, Options,
24    ShortComment, Tag, UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
25};
26
27#[must_use]
28#[derive(Serialize, Deserialize)]
29pub(crate) struct Config {
30    account: String,
31    login_token: String,
32    reader_id: u32,
33}
34
35/// Ciweimao client, use it to access Apis
36#[must_use]
37pub struct CiweimaoClient {
38    proxy: Option<Url>,
39    no_proxy: bool,
40    cert_path: Option<PathBuf>,
41
42    client: OnceCell<HTTPClient>,
43    client_rss: OnceCell<HTTPClient>,
44
45    db: OnceCell<NovelDB>,
46
47    config: RwLock<Option<Config>>,
48}
49
50impl Client for CiweimaoClient {
51    fn proxy(&mut self, proxy: Url) {
52        self.proxy = Some(proxy);
53    }
54
55    fn no_proxy(&mut self) {
56        self.no_proxy = true;
57    }
58
59    fn cert(&mut self, cert_path: PathBuf) {
60        self.cert_path = Some(cert_path);
61    }
62
63    async fn shutdown(&self) -> Result<(), Error> {
64        self.client().await?.save_cookies()?;
65        self.do_shutdown()?;
66        Ok(())
67    }
68
69    async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
70        self.client().await?.add_cookie(cookie_str, url)
71    }
72
73    async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
74        assert!(!username.is_empty());
75        assert!(password.is_some());
76
77        let password = password.unwrap();
78
79        let config = match self.verify_type(&username).await? {
80            VerifyType::None => {
81                tracing::info!("No verification required");
82                self.no_verification_login(username, password).await?
83            }
84            VerifyType::Geetest => {
85                tracing::info!("Verify with Geetest");
86                self.geetest_login(username, password).await?
87            }
88            VerifyType::VerifyCode => {
89                tracing::info!("Verify with SMS verification code");
90                self.sms_login(username, password).await?
91            }
92        };
93
94        self.save_token(config);
95
96        Ok(())
97    }
98
99    async fn logged_in(&self) -> Result<bool, Error> {
100        if !self.has_token() {
101            return Ok(false);
102        }
103
104        let response: GenericResponse = self.post("/reader/get_my_info", EmptyRequest {}).await?;
105
106        if response.code == CiweimaoClient::LOGIN_EXPIRED {
107            Ok(false)
108        } else {
109            utils::check_response_success(response.code, response.tip)?;
110            Ok(true)
111        }
112    }
113
114    async fn user_info(&self) -> Result<UserInfo, Error> {
115        let response: UserInfoResponse = self.post("/reader/get_my_info", EmptyRequest {}).await?;
116        utils::check_response_success(response.code, response.tip)?;
117        let reader_info = response.data.unwrap().reader_info;
118
119        let user_info = UserInfo {
120            nickname: reader_info.reader_name.trim().to_string(),
121            avatar: reader_info.avatar_url,
122        };
123
124        Ok(user_info)
125    }
126
127    async fn money(&self) -> Result<u32, Error> {
128        let response: PropInfoResponse =
129            self.post("/reader/get_prop_info", EmptyRequest {}).await?;
130        utils::check_response_success(response.code, response.tip)?;
131        let prop_info = response.data.unwrap().prop_info;
132
133        Ok(prop_info.rest_hlb.parse()?)
134    }
135
136    async fn sign_in(&self) -> Result<(), Error> {
137        let response: GenericResponse = self
138            .post(
139                "/reader/get_task_bonus_with_sign_recommend",
140                SignRequest {
141                    // always 1, from `/task/get_all_task_list`
142                    task_type: 1,
143                },
144            )
145            .await?;
146        if utils::check_already_signed_in(&response.code) {
147            tracing::info!("{}", response.tip.unwrap().trim());
148        } else {
149            utils::check_response_success(response.code, response.tip)?;
150        }
151
152        Ok(())
153    }
154
155    async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
156        let shelf_ids = self.shelf_list().await?;
157        let mut result = Vec::new();
158
159        for shelf_id in shelf_ids {
160            let response: BookshelfResponse = self
161                .post(
162                    "/bookshelf/get_shelf_book_list_new",
163                    BookshelfRequest {
164                        shelf_id,
165                        count: 9999,
166                        page: 0,
167                        order: "last_read_time",
168                    },
169                )
170                .await?;
171            utils::check_response_success(response.code, response.tip)?;
172
173            for novel_info in response.data.unwrap().book_list {
174                result.push(novel_info.book_info.book_id.parse()?);
175            }
176        }
177
178        Ok(result)
179    }
180
181    async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
182        assert!(id > 0);
183
184        let response: NovelInfoResponse = self
185            .post("/book/get_info_by_id", NovelInfoRequest { book_id: id })
186            .await?;
187        if response.code == CiweimaoClient::NOT_FOUND {
188            return Ok(None);
189        }
190        utils::check_response_success(response.code, response.tip)?;
191
192        let data = response.data.unwrap().book_info;
193        let novel_info = NovelInfo {
194            id,
195            name: data.book_name.trim().to_string(),
196            author_name: data.author_name.trim().to_string(),
197            cover_url: data.cover,
198            introduction: super::parse_multi_line(data.description),
199            word_count: Some(data.total_word_count.parse()?),
200            is_vip: Some(data.is_paid),
201            is_finished: Some(data.up_status),
202            create_time: data.newtime,
203            update_time: Some(data.uptime),
204            category: self.parse_category(data.category_index).await?,
205            tags: self.parse_tags(data.tag_list).await?,
206        };
207
208        Ok(Some(novel_info))
209    }
210
211    async fn comments(
212        &self,
213        id: u32,
214        comment_type: CommentType,
215        need_replies: bool,
216        page: u16,
217        size: u16,
218    ) -> Result<Option<Vec<Comment>>, Error> {
219        let r#type = match comment_type {
220            // 1 讨论
221            // 2 长评
222            CommentType::Short => 1,
223            CommentType::Long => 2,
224        };
225
226        let response: ReviewResponse = self
227            .post(
228                "/book/get_review_list",
229                ReviewRequest {
230                    book_id: id,
231                    r#type,
232                    page,
233                    count: size,
234                },
235            )
236            .await?;
237        utils::check_response_success(response.code, response.tip)?;
238        let review_list = response.data.unwrap().review_list;
239
240        if review_list.is_empty() {
241            return Ok(None);
242        }
243
244        let mut result = Vec::with_capacity(review_list.len());
245
246        match comment_type {
247            CommentType::Short => {
248                for review in review_list {
249                    // 部分评论为空白字符
250                    let Some(content) = super::parse_multi_line(review.review_content) else {
251                        continue;
252                    };
253
254                    let review_id: u32 = review.review_id.parse()?;
255                    // 评论数量可能为负数,e.g. 100089784
256                    let comment_amount = review.comment_amount.parse::<u16>().unwrap_or(0);
257
258                    let replies = if need_replies && comment_amount > 0 {
259                        self.review_comment(review_id, comment_amount).await?
260                    } else {
261                        None
262                    };
263
264                    let comment = ShortComment {
265                        id: review_id,
266                        user: UserInfo {
267                            nickname: review.reader_info.reader_name.trim().to_string(),
268                            avatar: review.reader_info.avatar_url,
269                        },
270                        content,
271                        create_time: Some(review.ctime),
272                        like_count: Some(review.like_amount.parse()?),
273                        replies,
274                    };
275
276                    result.push(Comment::Short(comment));
277                }
278            }
279            CommentType::Long => {
280                for review in review_list {
281                    let Some(content) = super::parse_multi_line(review.review_content) else {
282                        continue;
283                    };
284
285                    let review_id: u32 = review.review_id.parse()?;
286                    let comment_amount: u16 = review.comment_amount.parse()?;
287
288                    let replies = if need_replies && comment_amount > 0 {
289                        self.review_comment(review_id, comment_amount).await?
290                    } else {
291                        None
292                    };
293
294                    let comment = LongComment {
295                        id: review_id,
296                        user: UserInfo {
297                            nickname: review.reader_info.reader_name.trim().to_string(),
298                            avatar: review.reader_info.avatar_url,
299                        },
300                        title: review.title.trim().to_string(),
301                        content,
302                        create_time: Some(review.ctime),
303                        like_count: Some(review.like_amount.parse()?),
304                        replies,
305                    };
306
307                    result.push(Comment::Long(comment));
308                }
309            }
310        }
311
312        Ok(Some(result))
313    }
314
315    async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
316        let response: VolumesResponse = self
317            .post(
318                "/chapter/get_updated_chapter_by_division_new",
319                VolumesRequest { book_id: id },
320            )
321            .await?;
322        utils::check_response_success(response.code, response.tip)?;
323        let chapter_list = response.data.unwrap().chapter_list;
324
325        let chapter_prices = self.chapter_prices(id).await?;
326
327        let mut volume_infos = VolumeInfos::new();
328        for item in chapter_list {
329            let mut volume_info = VolumeInfo {
330                id: item.division_id.parse()?,
331                title: item.division_name.trim().to_string(),
332                chapter_infos: Vec::new(),
333            };
334
335            for chapter in item.chapter_list {
336                let chapter_id: u32 = chapter.chapter_id.parse()?;
337                let price = chapter_prices.get(&chapter_id).copied();
338                let mut is_valid = true;
339
340                // e.g. 该章节未审核通过
341                if price.is_none() {
342                    tracing::info!("Price not found: {chapter_id}");
343                    is_valid = false;
344                }
345
346                let chapter_info = ChapterInfo {
347                    novel_id: Some(id),
348                    id: chapter_id,
349                    title: chapter.chapter_title.trim().to_string(),
350                    word_count: Some(chapter.word_count.parse()?),
351                    // mtime 应为更新时间,但是 Android 端存在 bug,似乎导致 mtime 为创建时间
352                    create_time: Some(chapter.mtime),
353                    update_time: None,
354                    is_vip: Some(chapter.is_paid),
355                    price,
356                    payment_required: Some(!chapter.auth_access),
357                    is_valid: Some(chapter.is_valid && is_valid),
358                };
359
360                volume_info.chapter_infos.push(chapter_info);
361            }
362
363            volume_infos.push(volume_info);
364        }
365
366        Ok(Some(volume_infos))
367    }
368
369    async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
370        Ok(self
371            .content_infos_multiple(slice::from_ref(info))
372            .await?
373            .remove(0))
374    }
375
376    async fn content_infos_multiple(
377        &self,
378        infos: &[ChapterInfo],
379    ) -> Result<Vec<ContentInfos>, Error> {
380        let mut contents = HashMap::new();
381        let mut need_to_download = Vec::new();
382
383        for info in infos {
384            match self.db().await?.find_text(info).await? {
385                FindTextResult::Ok(content) => {
386                    contents.insert(info.id, content);
387                }
388                _ => {
389                    need_to_download.push(info.id);
390                }
391            }
392        }
393
394        if !need_to_download.is_empty() {
395            self.check_download_cpt().await?;
396
397            let cmd = self.chapter_cmd(&need_to_download).await?;
398            let key = crate::sha256(cmd.as_bytes());
399
400            let response: ChapsResponse = self
401                .post(
402                    "/chapter/download_cpt",
403                    ChapsRequest {
404                        chapter_id: itertools::join(&need_to_download, ","),
405                        chapter_command: cmd,
406                    },
407                )
408                .await?;
409            utils::check_response_success(response.code, response.tip)?;
410
411            let chapter_infos =
412                simdutf8::basic::from_utf8(&crate::aes_256_cbc_no_iv_base64_decrypt(
413                    key,
414                    &response.data.unwrap().chapter_infos,
415                )?)?
416                .to_string();
417            let chapter_infos: Vec<ChapsInfo> = sonic_rs::from_str(&chapter_infos)?;
418            if chapter_infos.len() != need_to_download.len() {
419                return Err(Error::NovelApi(String::from(
420                    "The number of chapter downloads is insufficient",
421                )));
422            }
423
424            for (index, id) in need_to_download.iter().enumerate() {
425                let content = chapter_infos[index].txt_content.clone();
426                if content.trim().is_empty() {
427                    return Err(Error::NovelApi(String::from("Content is empty")));
428                }
429
430                contents.insert(*id, content);
431            }
432
433            for info in infos {
434                match self.db().await?.find_text(info).await? {
435                    FindTextResult::Ok(_) => (),
436                    other => match other {
437                        FindTextResult::None => {
438                            self.db()
439                                .await?
440                                .insert_text(info, contents.get(&info.id).unwrap())
441                                .await?
442                        }
443                        FindTextResult::Outdate => {
444                            self.db()
445                                .await?
446                                .update_text(info, contents.get(&info.id).unwrap())
447                                .await?
448                        }
449                        FindTextResult::Ok(_) => (),
450                    },
451                }
452            }
453        }
454
455        let mut result = Vec::new();
456        for info in infos {
457            let mut content_infos = ContentInfos::new();
458            for line in contents
459                .get(&info.id)
460                .unwrap()
461                .lines()
462                .map(|line| line.trim())
463                .filter(|line| !line.is_empty())
464            {
465                if line.starts_with("<img") {
466                    if let Some(url) = CiweimaoClient::parse_image_url(line) {
467                        content_infos.push(ContentInfo::Image(url));
468                    }
469                } else {
470                    content_infos.push(ContentInfo::Text(line.to_string()));
471                }
472            }
473
474            result.push(content_infos);
475        }
476
477        Ok(result)
478    }
479
480    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
481        let response: GenericResponse = self
482            .post(
483                "/chapter/buy",
484                OrderChapterRequest {
485                    chapter_id: info.id.to_string(),
486                },
487            )
488            .await?;
489        utils::check_response_success(response.code, response.tip)?;
490
491        Ok(())
492    }
493
494    async fn order_novel(&self, id: u32, infos: &VolumeInfos) -> Result<(), Error> {
495        assert!(id > 0);
496
497        let mut chapter_id_list = Vec::new();
498        for volume in infos {
499            for chapter in &volume.chapter_infos {
500                if chapter.payment_required() {
501                    chapter_id_list.push(chapter.id.to_string());
502                }
503            }
504        }
505        if chapter_id_list.is_empty() {
506            return Ok(());
507        }
508
509        let chapter_id_list = sonic_rs::json!(chapter_id_list).to_string();
510
511        let response: GenericResponse = self
512            .post("/chapter/buy_multi", OrderNovelRequest { chapter_id_list })
513            .await?;
514        utils::check_response_success(response.code, response.tip)?;
515
516        Ok(())
517    }
518
519    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
520        match self.db().await?.find_image(url).await? {
521            FindImageResult::Ok(image) => Ok(image),
522            FindImageResult::None => {
523                let response = self.get_rss(url).await?;
524                let bytes = response.bytes().await?;
525
526                let image = ImageReader::new(Cursor::new(&bytes))
527                    .with_guessed_format()?
528                    .decode()?;
529
530                self.db().await?.insert_image(url, bytes).await?;
531
532                Ok(image)
533            }
534        }
535    }
536
537    async fn categories(&self) -> Result<&Vec<Category>, Error> {
538        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
539
540        CATEGORIES
541            .get_or_try_init(|| async {
542                let response: CategoryResponse =
543                    self.post("/meta/get_meta_data", EmptyRequest {}).await?;
544                utils::check_response_success(response.code, response.tip)?;
545
546                let mut result = Vec::new();
547                for category in response.data.unwrap().category_list {
548                    for category_detail in category.category_detail {
549                        result.push(Category {
550                            id: Some(category_detail.category_index.parse()?),
551                            parent_id: None,
552                            name: category_detail.category_name.trim().to_string(),
553                        });
554                    }
555                }
556
557                result.sort_unstable_by_key(|x| x.id.unwrap());
558
559                Ok(result)
560            })
561            .await
562    }
563
564    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
565        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
566
567        TAGS.get_or_try_init(|| async {
568            let response: TagResponse = self
569                .post("/book/get_official_tag_list", EmptyRequest {})
570                .await?;
571            utils::check_response_success(response.code, response.tip)?;
572
573            let mut result = Vec::new();
574            for tag in response.data.unwrap().official_tag_list {
575                result.push(Tag {
576                    id: None,
577                    name: tag.tag_name.trim().to_string(),
578                });
579            }
580
581            result.push(Tag {
582                id: None,
583                name: String::from("橘子"),
584            });
585            result.push(Tag {
586                id: None,
587                name: String::from("变身"),
588            });
589            result.push(Tag {
590                id: None,
591                name: String::from("性转"),
592            });
593            result.push(Tag {
594                id: None,
595                name: String::from("纯百"),
596            });
597
598            Ok(result)
599        })
600        .await
601    }
602
603    async fn search_infos(
604        &self,
605        option: &Options,
606        page: u16,
607        size: u16,
608    ) -> Result<Option<Vec<u32>>, Error> {
609        let mut category_index = 0;
610        if let Some(category) = &option.category {
611            category_index = category.id.unwrap();
612        }
613
614        let mut tags_vec = Vec::new();
615        if let Some(tags) = &option.tags {
616            for tag in tags {
617                tags_vec.push(sonic_rs::json!({
618                    "tag": tag.name,
619                    "filter": "1"
620                }));
621            }
622        }
623
624        let is_paid = option.is_vip.map(|is_vip| if is_vip { 1 } else { 0 });
625
626        let up_status = option
627            .is_finished
628            .map(|is_finished| if is_finished { 1 } else { 0 });
629
630        let mut filter_word = None;
631        if let Some(word_count) = &option.word_count {
632            match word_count {
633                WordCountRange::RangeTo(range_to) => {
634                    if range_to.end < 30_0000 {
635                        filter_word = Some(1);
636                    }
637                }
638                WordCountRange::Range(range) => {
639                    if range.start >= 30_0000 && range.end < 50_0000 {
640                        filter_word = Some(2);
641                    } else if range.start >= 50_0000 && range.end < 100_0000 {
642                        filter_word = Some(3);
643                    } else if range.start >= 100_0000 && range.end < 200_0000 {
644                        filter_word = Some(4);
645                    }
646                }
647                WordCountRange::RangeFrom(range_from) => {
648                    if range_from.start >= 200_0000 {
649                        filter_word = Some(5);
650                    }
651                }
652            }
653        }
654
655        let mut filter_uptime = None;
656        if let Some(update_days) = option.update_days {
657            if update_days <= 3 {
658                filter_uptime = Some(1)
659            } else if update_days <= 7 {
660                filter_uptime = Some(2)
661            } else if update_days <= 15 {
662                filter_uptime = Some(3)
663            } else if update_days <= 30 {
664                filter_uptime = Some(4)
665            }
666        }
667
668        let order = if option.keyword.is_some() {
669            // When using keyword search, many irrelevant items will appear in the search results
670            // If you use sorting, you will not be able to obtain the target items
671            None
672        } else {
673            // 人气排序
674            Some("week_click")
675        };
676
677        let response: SearchResponse = self
678            .post(
679                "/bookcity/get_filter_search_book_list",
680                SearchRequest {
681                    count: size,
682                    page,
683                    order,
684                    category_index,
685                    tags: sonic_rs::json!(tags_vec).to_string(),
686                    key: option.keyword.clone(),
687                    is_paid,
688                    up_status,
689                    filter_uptime,
690                    filter_word,
691                },
692            )
693            .await?;
694        utils::check_response_success(response.code, response.tip)?;
695
696        let book_list = response.data.unwrap().book_list;
697        if book_list.is_empty() {
698            return Ok(None);
699        }
700
701        let mut result = Vec::new();
702        let sys_tags = self.tags().await?;
703
704        for novel_info in book_list {
705            let mut tag_names = Vec::new();
706            for tag in novel_info.tag_list {
707                if let Some(sys_tag) = sys_tags.iter().find(|x| x.name == tag.tag_name.trim()) {
708                    tag_names.push(sys_tag.name.clone());
709                }
710            }
711
712            if CiweimaoClient::match_update_days(option, novel_info.uptime)
713                && CiweimaoClient::match_excluded_tags(option, tag_names)
714                && CiweimaoClient::match_word_count(option, novel_info.total_word_count.parse()?)
715            {
716                result.push(novel_info.book_id.parse()?);
717            }
718        }
719
720        Ok(Some(result))
721    }
722
723    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
724        match comment_type {
725            CommentType::Short => true,
726            CommentType::Long => true,
727        }
728    }
729}
730
731#[must_use]
732enum VerifyType {
733    None,
734    Geetest,
735    VerifyCode,
736}
737
738impl CiweimaoClient {
739    async fn review_comment(
740        &self,
741        review_id: u32,
742        comment_amount: u16,
743    ) -> Result<Option<Vec<ShortComment>>, Error> {
744        let response: ReviewCommentResponse = self
745            .post(
746                "/book/get_review_comment_list",
747                ReviewCommentRequest {
748                    review_id,
749                    page: 0,
750                    count: comment_amount,
751                },
752            )
753            .await?;
754        utils::check_response_success(response.code, response.tip)?;
755        let review_comment_list = response.data.unwrap().review_comment_list;
756
757        let mut result = Vec::with_capacity(review_comment_list.len());
758
759        for comment in review_comment_list {
760            let Some(content) = super::parse_multi_line(comment.comment_content) else {
761                continue;
762            };
763
764            let replies = if comment.review_comment_reply_list.is_empty() {
765                None
766            } else if
767            // 返回的最大数量似乎为 3 个
768            comment.review_comment_reply_list.len() <= 2 {
769                let mut result = Vec::with_capacity(2);
770
771                for reply in comment.review_comment_reply_list {
772                    let Some(content) = super::parse_multi_line(reply.reply_content) else {
773                        continue;
774                    };
775
776                    result.push(ShortComment {
777                        id: reply.reply_id.parse()?,
778                        user: UserInfo {
779                            nickname: reply.reader_info.reader_name.trim().to_string(),
780                            avatar: reply.reader_info.avatar_url,
781                        },
782                        content,
783                        create_time: Some(reply.ctime),
784                        like_count: None,
785                        replies: None,
786                    })
787                }
788
789                if result.is_empty() {
790                    None
791                } else {
792                    result.sort_unstable_by_key(|x| x.create_time.unwrap());
793                    result.dedup();
794                    Some(result)
795                }
796            } else {
797                self.review_comment_reply(comment.comment_id.parse()?)
798                    .await?
799            };
800
801            result.push(ShortComment {
802                id: comment.comment_id.parse()?,
803                user: UserInfo {
804                    nickname: comment.reader_info.reader_name.trim().to_string(),
805                    avatar: comment.reader_info.avatar_url,
806                },
807                content,
808                create_time: Some(comment.ctime),
809                like_count: None,
810                replies,
811            });
812        }
813
814        if result.is_empty() {
815            Ok(None)
816        } else {
817            result.sort_unstable_by_key(|x| x.create_time.unwrap());
818            result.dedup();
819            Ok(Some(result))
820        }
821    }
822
823    async fn review_comment_reply(
824        &self,
825        comment_id: u32,
826    ) -> Result<Option<Vec<ShortComment>>, Error> {
827        let response: ReviewCommentReplyResponse = self
828            .post(
829                "/book/get_review_comment_reply_list",
830                ReviewCommentReplyRequest {
831                    comment_id,
832                    page: 0,
833                    count: 9999,
834                },
835            )
836            .await?;
837        utils::check_response_success(response.code, response.tip)?;
838
839        let mut result = Vec::with_capacity(4);
840
841        for reply in response.data.unwrap().review_comment_reply_list {
842            let Some(content) = super::parse_multi_line(reply.reply_content) else {
843                continue;
844            };
845
846            let comment = ShortComment {
847                id: reply.reply_id.parse()?,
848                user: UserInfo {
849                    nickname: reply.reader_info.reader_name.trim().to_string(),
850                    avatar: reply.reader_info.avatar_url,
851                },
852                content,
853                create_time: Some(reply.ctime),
854                like_count: None,
855                replies: None,
856            };
857
858            result.push(comment);
859        }
860
861        if result.is_empty() {
862            Ok(None)
863        } else {
864            result.sort_unstable_by_key(|x| x.create_time.unwrap());
865            result.dedup();
866            Ok(Some(result))
867        }
868    }
869
870    async fn verify_type<T>(&self, username: T) -> Result<VerifyType, Error>
871    where
872        T: AsRef<str>,
873    {
874        let response: UseGeetestResponse = self
875            .post(
876                "/signup/use_geetest",
877                UseGeetestRequest {
878                    login_name: username.as_ref().to_string(),
879                },
880            )
881            .await?;
882        utils::check_response_success(response.code, response.tip)?;
883
884        let need_use_geetest = response.data.unwrap().need_use_geetest;
885        if need_use_geetest == "0" {
886            Ok(VerifyType::None)
887        } else if need_use_geetest == "1" {
888            Ok(VerifyType::Geetest)
889        } else if need_use_geetest == "2" {
890            Ok(VerifyType::VerifyCode)
891        } else {
892            unreachable!("The value range of need_use_geetest is 0..=2");
893        }
894    }
895
896    async fn no_verification_login(
897        &self,
898        username: String,
899        password: String,
900    ) -> Result<Config, Error> {
901        let response: LoginResponse = self
902            .post(
903                "/signup/login",
904                LoginRequest {
905                    login_name: username.clone(),
906                    passwd: CiweimaoClient::rsa_encrypt(&password)?,
907                    sign: CiweimaoClient::rsa_encrypt(&format!("{username}_{password}"))?,
908                },
909            )
910            .await?;
911        utils::check_response_success(response.code, response.tip)?;
912
913        let data = response.data.unwrap();
914
915        Ok(Config {
916            account: data.reader_info.account,
917            login_token: data.login_token,
918            reader_id: data.reader_info.reader_id.parse().unwrap(),
919        })
920    }
921
922    async fn geetest_login(&self, username: String, password: String) -> Result<Config, Error> {
923        let info = self.geetest_info(&username).await?;
924        let geetest_challenge = info.challenge.clone();
925
926        let validate = if info.success == 1 {
927            server::run_geetest(info).await?
928        } else {
929            geetest_challenge.clone()
930        };
931
932        let response: LoginResponse = self
933            .post(
934                "/signup/login",
935                LoginCaptchaRequest {
936                    login_name: username.clone(),
937                    passwd: CiweimaoClient::rsa_encrypt(&password)?,
938                    sign: CiweimaoClient::rsa_encrypt(&format!("{username}_{password}"))?,
939                    geetest_seccode: validate.clone() + "|jordan",
940                    geetest_validate: validate,
941                    geetest_challenge,
942                },
943            )
944            .await?;
945        utils::check_response_success(response.code, response.tip)?;
946
947        let data = response.data.unwrap();
948
949        Ok(Config {
950            account: data.reader_info.account,
951            login_token: data.login_token,
952            reader_id: data.reader_info.reader_id.parse().unwrap(),
953        })
954    }
955
956    async fn geetest_info<T>(&self, username: T) -> Result<GeetestInfoResponse, Error>
957    where
958        T: AsRef<str>,
959    {
960        let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
961
962        let response: GeetestInfoResponse = self
963            .get_query(
964                "/signup/geetest_first_register",
965                GeetestInfoRequest {
966                    t: timestamp,
967                    user_id: username.as_ref().to_string(),
968                },
969            )
970            .await?;
971
972        Ok(response)
973    }
974
975    async fn sms_login(&self, username: String, password: String) -> Result<Config, Error> {
976        let timestamp = SystemTime::now()
977            .duration_since(UNIX_EPOCH)
978            .unwrap()
979            .as_millis();
980
981        let response: SendVerifyCodeResponse = self
982            .post(
983                "/signup/send_verify_code",
984                SendVerifyCodeRequest {
985                    login_name: username.clone(),
986                    timestamp,
987                    // always 5
988                    verify_type: 5,
989                    hashvalue: self.hashvalue(timestamp),
990                },
991            )
992            .await?;
993        utils::check_response_success(response.code, response.tip)?;
994
995        let response: LoginResponse = self
996            .post(
997                "/signup/login",
998                LoginSMSRequest {
999                    login_name: username.clone(),
1000                    passwd: CiweimaoClient::rsa_encrypt(&password)?,
1001                    sign: CiweimaoClient::rsa_encrypt(&format!("{username}_{password}"))?,
1002                    to_code: response.data.unwrap().to_code,
1003                    ver_code: crate::input("Please enter SMS verification code")?,
1004                },
1005            )
1006            .await?;
1007        utils::check_response_success(response.code, response.tip)?;
1008
1009        let data = response.data.unwrap();
1010
1011        Ok(Config {
1012            account: data.reader_info.account,
1013            login_token: data.login_token,
1014            reader_id: data.reader_info.reader_id.parse().unwrap(),
1015        })
1016    }
1017
1018    async fn shelf_list(&self) -> Result<Vec<u32>, Error> {
1019        let response: ShelfListResponse = self
1020            .post("/bookshelf/get_shelf_list", EmptyRequest {})
1021            .await?;
1022        utils::check_response_success(response.code, response.tip)?;
1023
1024        let mut result = Vec::new();
1025        for shelf in response.data.unwrap().shelf_list {
1026            result.push(shelf.shelf_id.parse()?);
1027        }
1028
1029        Ok(result)
1030    }
1031
1032    async fn chapter_prices(&self, novel_id: u32) -> Result<HashMap<u32, u16>, Error> {
1033        let response: PriceResponse = self
1034            .post(
1035                "/chapter/get_chapter_permission_list",
1036                PriceRequest { book_id: novel_id },
1037            )
1038            .await?;
1039        utils::check_response_success(response.code, response.tip)?;
1040        let chapter_permission_list = response.data.unwrap().chapter_permission_list;
1041
1042        let mut result = HashMap::new();
1043
1044        for item in chapter_permission_list {
1045            result.insert(item.chapter_id.parse()?, item.unit_hlb.parse()?);
1046        }
1047
1048        Ok(result)
1049    }
1050
1051    async fn check_download_cpt(&self) -> Result<(), Error> {
1052        let response: GenericResponse = self
1053            .post("/chapter/check_download_cpt", EmptyRequest {})
1054            .await?;
1055        if response.code == CiweimaoClient::NEED_TO_UPGRADE_VERSION {
1056            // NOTE 当前 success 总是 0,无法正常使用验证码
1057            let _ = self.geetest_info(self.try_account()).await?;
1058        } else {
1059            utils::check_response_success(response.code, response.tip)?;
1060        }
1061
1062        Ok(())
1063    }
1064
1065    async fn chapter_cmd(&self, ids: &[u32]) -> Result<String, Error> {
1066        let response: ChapterCmdResponse = self
1067            .post(
1068                "/chapter/get_chapter_download_cmd",
1069                ChapterCmdRequest {
1070                    chapter_id: itertools::join(ids, ","),
1071                },
1072            )
1073            .await?;
1074        utils::check_response_success(response.code, response.tip)?;
1075
1076        Ok(response.data.unwrap().command)
1077    }
1078
1079    fn match_update_days(option: &Options, update_time: NaiveDateTime) -> bool {
1080        if option.update_days.is_none() {
1081            return true;
1082        }
1083
1084        let other_time = Shanghai.from_local_datetime(&update_time).unwrap()
1085            + Duration::try_days(*option.update_days.as_ref().unwrap() as i64).unwrap();
1086
1087        Local::now() <= other_time
1088    }
1089
1090    fn match_word_count(option: &Options, word_count: u32) -> bool {
1091        if option.word_count.is_none() {
1092            return true;
1093        }
1094
1095        match option.word_count.as_ref().unwrap() {
1096            WordCountRange::RangeTo(range_to) => word_count <= range_to.end,
1097            WordCountRange::Range(range) => range.start <= word_count && word_count <= range.end,
1098            WordCountRange::RangeFrom(range_from) => range_from.start <= word_count,
1099        }
1100    }
1101
1102    fn match_excluded_tags(option: &Options, tag_ids: Vec<String>) -> bool {
1103        if option.excluded_tags.is_none() {
1104            return true;
1105        }
1106
1107        tag_ids.iter().all(|name| {
1108            !option
1109                .excluded_tags
1110                .as_ref()
1111                .unwrap()
1112                .iter()
1113                .any(|tag| tag.name == *name)
1114        })
1115    }
1116
1117    fn parse_url<T>(str: T) -> Option<Url>
1118    where
1119        T: AsRef<str>,
1120    {
1121        let str = str.as_ref();
1122        if str.is_empty() {
1123            return None;
1124        }
1125
1126        match Url::parse(str) {
1127            Ok(url) => Some(url),
1128            Err(error) => {
1129                tracing::error!("Url parse failed: {error}, content: {str}");
1130                None
1131            }
1132        }
1133    }
1134
1135    async fn parse_tags(&self, tag_list: Vec<NovelInfoTag>) -> Result<Option<Vec<Tag>>, Error> {
1136        let sys_tags = self.tags().await?;
1137
1138        let mut result = Vec::new();
1139        for tag in tag_list {
1140            let name = tag.tag_name.trim().to_string();
1141
1142            // Remove non-system tags
1143            if sys_tags.iter().any(|item| item.name == name) {
1144                result.push(Tag { id: None, name });
1145            } else {
1146                tracing::info!("This tag is not a system tag and is ignored: {name}");
1147            }
1148        }
1149
1150        if result.is_empty() {
1151            Ok(None)
1152        } else {
1153            Ok(Some(result))
1154        }
1155    }
1156
1157    async fn parse_category<T>(&self, str: T) -> Result<Option<Category>, Error>
1158    where
1159        T: AsRef<str>,
1160    {
1161        let str = str.as_ref();
1162        if str.is_empty() {
1163            return Ok(None);
1164        }
1165
1166        let categories = self.categories().await?;
1167
1168        match str.parse::<u16>() {
1169            Ok(index) => match categories.iter().find(|item| item.id == Some(index)) {
1170                Some(category) => Ok(Some(category.clone())),
1171                None => {
1172                    tracing::error!("The category index does not exist: {str}");
1173                    Ok(None)
1174                }
1175            },
1176            Err(error) => {
1177                tracing::error!("category_index parse failed: {error}");
1178                Ok(None)
1179            }
1180        }
1181    }
1182
1183    fn parse_image_url<T>(str: T) -> Option<Url>
1184    where
1185        T: AsRef<str>,
1186    {
1187        let str = str.as_ref();
1188        if str.is_empty() {
1189            return None;
1190        }
1191
1192        let fragment = Html::parse_fragment(str);
1193        let selector = Selector::parse("img").unwrap();
1194
1195        let element = fragment.select(&selector).next();
1196        if element.is_none() {
1197            tracing::error!("No `img` element exists: {str}");
1198            return None;
1199        }
1200        let element = element.unwrap();
1201
1202        let url = element.value().attr("src");
1203        if url.is_none() {
1204            tracing::error!("No `src` attribute exists: {str}");
1205            return None;
1206        }
1207        let url = url.unwrap();
1208
1209        CiweimaoClient::parse_url(url.trim())
1210    }
1211}