novel_api/ciyuanji/
mod.rs

1mod structure;
2mod utils;
3
4use std::io::Cursor;
5use std::path::PathBuf;
6use std::sync::RwLock;
7
8use chrono::{Duration, Local, NaiveDateTime, TimeZone};
9use chrono_tz::Asia::Shanghai;
10use image::{DynamicImage, ImageReader};
11use serde::{Deserialize, Serialize};
12use tokio::sync::OnceCell;
13use url::Url;
14
15use self::structure::*;
16use crate::{
17    Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
18    FindImageResult, FindTextResult, HTTPClient, NovelDB, NovelInfo, Options, ShortComment, Tag,
19    UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
20};
21
22#[must_use]
23#[derive(Serialize, Deserialize)]
24pub(crate) struct Config {
25    token: String,
26}
27
28/// Ciyuanji client, use it to access Apis
29#[must_use]
30pub struct CiyuanjiClient {
31    proxy: Option<Url>,
32    no_proxy: bool,
33    cert_path: Option<PathBuf>,
34
35    client: OnceCell<HTTPClient>,
36    client_rss: OnceCell<HTTPClient>,
37
38    db: OnceCell<NovelDB>,
39
40    config: RwLock<Option<Config>>,
41}
42
43impl Client for CiyuanjiClient {
44    fn proxy(&mut self, proxy: Url) {
45        self.proxy = Some(proxy);
46    }
47
48    fn no_proxy(&mut self) {
49        self.no_proxy = true;
50    }
51
52    fn cert(&mut self, cert_path: PathBuf) {
53        self.cert_path = Some(cert_path);
54    }
55
56    async fn shutdown(&self) -> Result<(), Error> {
57        self.client().await?.save_cookies()?;
58        self.do_shutdown()?;
59        Ok(())
60    }
61
62    async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
63        self.client().await?.add_cookie(cookie_str, url)
64    }
65
66    async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
67        assert!(!username.is_empty());
68        assert!(password.is_none());
69
70        let response: GenericResponse = self
71            .post(
72                "/login/getPhoneCode",
73                PhoneCodeRequest {
74                    phone: username.clone(),
75                    // always 1
76                    sms_type: "1",
77                },
78            )
79            .await?;
80        utils::check_response_success(response.code, response.msg, response.ok)?;
81
82        let response: LoginResponse = self
83            .post(
84                "/login/phone",
85                LoginRequest {
86                    phone: username,
87                    phone_code: crate::input("Please enter SMS verification code")?,
88                },
89            )
90            .await?;
91        utils::check_response_success(response.code, response.msg, response.ok)?;
92
93        self.save_token(Config {
94            token: response.data.unwrap().user_info.unwrap().token,
95        });
96
97        Ok(())
98    }
99
100    async fn logged_in(&self) -> Result<bool, Error> {
101        if !self.has_token() {
102            return Ok(false);
103        }
104
105        let response: GenericResponse = self.get("/user/getUserInfo").await?;
106
107        if response.code == CiyuanjiClient::FAILED {
108            Ok(false)
109        } else {
110            utils::check_response_success(response.code, response.msg, response.ok)?;
111            Ok(true)
112        }
113    }
114
115    async fn user_info(&self) -> Result<UserInfo, Error> {
116        let response: UserInfoResponse = self.get("/user/getUserInfo").await?;
117        utils::check_response_success(response.code, response.msg, response.ok)?;
118        let cm_user = response.data.unwrap().cm_user.unwrap();
119
120        let user_info = UserInfo {
121            nickname: cm_user.nick_name.trim().to_string(),
122            avatar: Some(cm_user.img_url),
123        };
124
125        Ok(user_info)
126    }
127
128    async fn money(&self) -> Result<u32, Error> {
129        let response: MoneyResponse = self.get("/account/getAccountByUser").await?;
130        utils::check_response_success(response.code, response.msg, response.ok)?;
131        let account_info = response.data.unwrap().account_info.unwrap();
132
133        Ok(account_info.currency_balance + account_info.coupon_balance)
134    }
135
136    async fn sign_in(&self) -> Result<(), Error> {
137        let response: GenericResponse = self.post("/sign/sign", EmptyRequest {}).await?;
138        if utils::check_already_signed_in(&response.code, &response.msg) {
139            tracing::info!("{}", CiyuanjiClient::ALREADY_SIGNED_IN_MSG);
140        } else {
141            utils::check_response_success(response.code, response.msg, response.ok)?;
142        }
143
144        Ok(())
145    }
146
147    async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
148        let response: BookSelfResponse = self
149            .get_query(
150                "/bookrack/getUserBookRackList",
151                BookSelfRequest {
152                    page_no: 1,
153                    page_size: 9999,
154                    // 1 阅读
155                    // 2 更新
156                    rank_type: 1,
157                },
158            )
159            .await?;
160        utils::check_response_success(response.code, response.msg, response.ok)?;
161
162        let mut result = Vec::new();
163
164        for item in response.data.unwrap().book_rack_list.unwrap() {
165            result.push(item.book_id);
166        }
167
168        Ok(result)
169    }
170
171    async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
172        assert!(id > 0);
173
174        let response: BookDetailResponse = self
175            .get_query(
176                "/book/getBookDetail",
177                BookDetailRequest {
178                    book_id: id.to_string(),
179                },
180            )
181            .await?;
182        utils::check_response_success(response.code, response.msg, response.ok)?;
183        let data = response.data.unwrap();
184
185        if data.book.is_none() {
186            return Ok(None);
187        }
188
189        let book = data.book.unwrap();
190
191        // 该书不存在
192        if book.book_id == 0 {
193            return Ok(None);
194        }
195
196        let category = if book.second_classify.is_some() {
197            Some(Category {
198                id: Some(book.second_classify.unwrap()),
199                parent_id: Some(book.first_classify.unwrap()),
200                name: format!(
201                    "{}-{}",
202                    book.first_classify_name.unwrap().trim(),
203                    book.second_classify_name.unwrap().trim()
204                ),
205            })
206        } else if book.first_classify.is_some() {
207            Some(Category {
208                id: Some(book.first_classify.unwrap()),
209                parent_id: None,
210                name: book.first_classify_name.unwrap().trim().to_string(),
211            })
212        } else {
213            None
214        };
215
216        let novel_info = NovelInfo {
217            id,
218            name: book.book_name.unwrap().trim().to_string(),
219            author_name: book.author_name.unwrap().trim().to_string(),
220            cover_url: book.img_url,
221            introduction: super::parse_multi_line(book.notes.unwrap()),
222            word_count: CiyuanjiClient::parse_word_count(book.word_count),
223            is_vip: Some(book.is_vip.unwrap() == "1"),
224            is_finished: Some(book.end_state.unwrap() == "1"),
225            create_time: None,
226            update_time: book.latest_update_time,
227            category,
228            tags: self.parse_tags(book.tag_list.unwrap()).await?,
229        };
230
231        Ok(Some(novel_info))
232    }
233
234    async fn comments(
235        &self,
236        id: u32,
237        comment_type: CommentType,
238        need_replies: bool,
239        page: u16,
240        size: u16,
241    ) -> Result<Option<Vec<Comment>>, Error> {
242        assert!(matches!(comment_type, CommentType::Short));
243
244        let response: PostListResponse = self
245            .get_query(
246                "/apppost/getAppPostListByCircleId",
247                PostListRequest {
248                    circle_id: id.to_string(),
249                    circle_type: 1,
250                    page_no: page + 1,
251                    page_size: size,
252                    // 2 最新
253                    // 3 最热
254                    rank_type: 2,
255                },
256            )
257            .await?;
258        utils::check_response_success(response.code, response.msg, response.ok)?;
259        let data = response.data.unwrap().list.unwrap();
260
261        if data.is_empty() {
262            return Ok(None);
263        }
264
265        let mut result = Vec::with_capacity(data.len());
266
267        for post in data {
268            if need_replies {
269                if let Some(post) = self.post_detail(post.post_id).await? {
270                    result.push(Comment::Short(post));
271                }
272            } else {
273                let Some(content) = super::parse_multi_line(post.post_content) else {
274                    continue;
275                };
276
277                result.push(Comment::Short(ShortComment {
278                    id: post.post_id,
279                    user: UserInfo {
280                        nickname: post.nick_name.trim().to_string(),
281                        avatar: Some(post.user_img_url),
282                    },
283                    content,
284                    create_time: Some(post.create_time),
285                    like_count: Some(post.thumb_num),
286                    replies: None,
287                }));
288            }
289        }
290
291        Ok(Some(result))
292    }
293
294    async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
295        let response: ChapterListResponse = self
296            .get_query(
297                "/chapter/getChapterListByBookId",
298                VolumeInfosRequest {
299                    // 1 正序
300                    // 2 倒序
301                    sort_type: "1",
302                    page_no: "1",
303                    page_size: "9999",
304                    book_id: id.to_string(),
305                },
306            )
307            .await?;
308        utils::check_response_success(response.code, response.msg, response.ok)?;
309
310        let mut volumes = VolumeInfos::new();
311
312        let mut last_volume_id = 0;
313        let book_chapter = response.data.unwrap().book_chapter.unwrap();
314
315        let book_id = book_chapter.book_id;
316
317        if book_chapter.chapter_list.is_some() {
318            for chapter in book_chapter.chapter_list.unwrap() {
319                let volume_title = chapter.title.unwrap_or_default().trim().to_string();
320
321                if chapter.volume_id != last_volume_id {
322                    last_volume_id = chapter.volume_id;
323
324                    volumes.push(VolumeInfo {
325                        id: chapter.volume_id,
326                        title: volume_title.clone(),
327                        chapter_infos: Vec::new(),
328                    });
329                }
330
331                let last_volume_title = &mut volumes.last_mut().unwrap().title;
332                if last_volume_title.is_empty() && !volume_title.is_empty() {
333                    *last_volume_title = volume_title;
334                }
335
336                let chapter_info = ChapterInfo {
337                    novel_id: Some(book_id),
338                    id: chapter.chapter_id,
339                    title: chapter.chapter_name.trim().to_string(),
340                    is_vip: Some(chapter.is_fee == "1"),
341                    // 去除小数部分
342                    price: Some(chapter.price.parse::<f64>().unwrap() as u16),
343                    payment_required: Some(
344                        chapter.is_fee == "1" && chapter.is_buy == "0",
345                    ),
346                    is_valid: None,
347                    word_count: /*CiyuanjiClient::parse_word_count(chapter.word_count)*/Some(chapter.word_count),
348                    create_time: Some(chapter.publish_time),
349                    update_time: None,
350                };
351
352                volumes.last_mut().unwrap().chapter_infos.push(chapter_info);
353            }
354        }
355
356        Ok(Some(volumes))
357    }
358
359    async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
360        let mut content;
361
362        match self.db().await?.find_text(info).await? {
363            FindTextResult::Ok(str) => {
364                content = str;
365            }
366            other => {
367                let response: ContentResponse = self
368                    .get_query(
369                        "/chapter/getChapterContent",
370                        ContentRequest {
371                            book_id: info.novel_id.unwrap().to_string(),
372                            chapter_id: info.id.to_string(),
373                        },
374                    )
375                    .await?;
376                utils::check_response_success(response.code, response.msg, response.ok)?;
377                let chapter = response.data.unwrap().chapter.unwrap();
378
379                content = crate::des_ecb_base64_decrypt(
380                    CiyuanjiClient::DES_KEY,
381                    chapter.content.replace('\n', ""),
382                )?;
383
384                if content.trim().is_empty() {
385                    return Err(Error::NovelApi(String::from("Content is empty")));
386                }
387
388                if chapter.img_list.as_ref().is_some_and(|x| !x.is_empty()) {
389                    let mut content_lines: Vec<_> =
390                        content.lines().map(|x| x.to_string()).collect();
391
392                    for img in chapter.img_list.as_ref().unwrap() {
393                        let image_str = format!("[img]{}[/img]", img.img_url);
394
395                        // 被和谐章节可能图片依旧存在,但是对应的段落已经被删除
396                        if img.paragraph_index > content_lines.len() {
397                            tracing::warn!(
398                                "The paragraph index of the image is greater than the number of paragraphs: {} > {}, in {}({}), image will be inserted at the end",
399                                img.paragraph_index,
400                                content_lines.len(),
401                                info.title,
402                                info.id
403                            );
404
405                            content_lines.push(image_str);
406                        } else {
407                            content_lines.insert(img.paragraph_index, image_str);
408                        }
409                    }
410
411                    content = content_lines.join("\n");
412                }
413
414                match other {
415                    FindTextResult::None => self.db().await?.insert_text(info, &content).await?,
416                    FindTextResult::Outdate => self.db().await?.update_text(info, &content).await?,
417                    FindTextResult::Ok(_) => (),
418                }
419            }
420        }
421
422        let mut content_infos = ContentInfos::new();
423        for line in content
424            .lines()
425            .map(|line| line.trim())
426            .filter(|line| !line.is_empty())
427        {
428            if line.starts_with("[img") {
429                if let Some(url) = CiyuanjiClient::parse_image_url(line) {
430                    content_infos.push(ContentInfo::Image(url));
431                }
432            } else {
433                content_infos.push(ContentInfo::Text(line.to_string()));
434            }
435        }
436
437        Ok(content_infos)
438    }
439
440    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
441        let response: GenericResponse = self
442            .post(
443                "/order/consume",
444                OrderChapterRequest {
445                    // always 2
446                    view_type: "2",
447                    // always 1
448                    consume_type: "1",
449                    book_id: info.novel_id.unwrap().to_string(),
450                    product_id: info.id.to_string(),
451                    buy_count: "1",
452                },
453            )
454            .await?;
455        if utils::check_already_ordered(&response.code, &response.msg) {
456            tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
457        } else {
458            utils::check_response_success(response.code, response.msg, response.ok)?;
459        }
460
461        Ok(())
462    }
463
464    async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
465        assert!(id > 0);
466
467        let response: GenericResponse = self
468            .post(
469                "/order/consume",
470                OrderNovelRequest {
471                    // always 2
472                    view_type: "2",
473                    // always 1
474                    consume_type: "1",
475                    book_id: id.to_string(),
476                    is_all_unpaid: "1",
477                },
478            )
479            .await?;
480        if utils::check_already_ordered(&response.code, &response.msg) {
481            tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
482        } else {
483            utils::check_response_success(response.code, response.msg, response.ok)?;
484        }
485
486        Ok(())
487    }
488
489    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
490        match self.db().await?.find_image(url).await? {
491            FindImageResult::Ok(image) => Ok(image),
492            FindImageResult::None => {
493                let response = self.get_rss(url).await?;
494                let bytes = response.bytes().await?;
495
496                let image = ImageReader::new(Cursor::new(&bytes))
497                    .with_guessed_format()?
498                    .decode()?;
499
500                self.db().await?.insert_image(url, bytes).await?;
501
502                Ok(image)
503            }
504        }
505    }
506
507    async fn categories(&self) -> Result<&Vec<Category>, Error> {
508        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
509
510        CATEGORIES
511            .get_or_try_init(|| async {
512                let mut result = Vec::with_capacity(32);
513
514                // 1 男生
515                // 2 漫画
516                // 4 女生
517                self.get_categories("1", &mut result).await?;
518                self.get_categories("4", &mut result).await?;
519
520                result.sort_unstable_by_key(|x| x.id.unwrap());
521                result.dedup();
522                Ok(result)
523            })
524            .await
525    }
526
527    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
528        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
529
530        TAGS.get_or_try_init(|| async {
531            let mut result = Vec::with_capacity(64);
532
533            self.get_tags(1, &mut result).await?;
534            self.get_tags(4, &mut result).await?;
535
536            result.push(Tag {
537                id: Some(17),
538                name: String::from("无限流"),
539            });
540            result.push(Tag {
541                id: Some(19),
542                name: String::from("后宫"),
543            });
544            result.push(Tag {
545                id: Some(26),
546                name: String::from("变身"),
547            });
548            result.push(Tag {
549                id: Some(30),
550                name: String::from("百合"),
551            });
552            result.push(Tag {
553                id: Some(96),
554                name: String::from("变百"),
555            });
556            result.push(Tag {
557                id: Some(127),
558                name: String::from("性转"),
559            });
560            result.push(Tag {
561                id: Some(249),
562                name: String::from("吸血鬼"),
563            });
564            result.push(Tag {
565                id: Some(570),
566                name: String::from("纯百"),
567            });
568            result.push(Tag {
569                id: Some(1431),
570                name: String::from("复仇"),
571            });
572            result.push(Tag {
573                id: Some(1512),
574                name: String::from("魔幻"),
575            });
576            result.push(Tag {
577                id: Some(5793),
578                name: String::from("少女"),
579            });
580
581            result.sort_unstable_by_key(|x| x.id.unwrap());
582            result.dedup();
583            Ok(result)
584        })
585        .await
586    }
587
588    async fn search_infos(
589        &self,
590        option: &Options,
591        page: u16,
592        size: u16,
593    ) -> Result<Option<Vec<u32>>, Error> {
594        if option.keyword.is_some() {
595            self.do_search_with_keyword(option, page, size).await
596        } else {
597            self.do_search_without_keyword(option, page, size).await
598        }
599    }
600
601    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
602        match comment_type {
603            CommentType::Short => true,
604            CommentType::Long => false,
605        }
606    }
607}
608
609impl CiyuanjiClient {
610    pub async fn post_detail(&self, post_id: u32) -> Result<Option<ShortComment>, Error> {
611        let response: PostDetailResponse = self
612            .get_query(
613                "/apppost/getApppostDetail",
614                PostDetailRequest {
615                    post_id,
616                    page_no: 1,
617                    page_size: 9999,
618                    // 2 最新
619                    // 3 最热
620                    rank_type: 2,
621                },
622            )
623            .await?;
624        utils::check_response_success(response.code, response.msg, response.ok)?;
625
626        let post_detail = response.data.unwrap();
627        let Some(content) = super::parse_multi_line(post_detail.post_content) else {
628            return Ok(None);
629        };
630
631        let replies = if !post_detail.list.is_empty() {
632            let mut replies = Vec::with_capacity(post_detail.list.len());
633
634            for reply in post_detail.list {
635                let Some(content) = super::parse_multi_line(reply.reply_content) else {
636                    continue;
637                };
638
639                replies.push(ShortComment {
640                    id: reply.reply_id,
641                    user: UserInfo {
642                        nickname: reply.nick_name.trim().to_string(),
643                        avatar: Some(reply.user_img_url),
644                    },
645                    content,
646                    create_time: Some(reply.create_time),
647                    like_count: Some(reply.thumb_num),
648                    replies: if reply.reply_num == 0 {
649                        None
650                    } else {
651                        self.reply_list(reply.reply_id, reply.reply_num).await?
652                    },
653                });
654            }
655
656            if replies.is_empty() {
657                None
658            } else {
659                replies.sort_unstable_by_key(|x| x.create_time.unwrap());
660                replies.dedup();
661                Some(replies)
662            }
663        } else {
664            None
665        };
666
667        Ok(Some(ShortComment {
668            id: post_id,
669            user: UserInfo {
670                nickname: post_detail.nick_name.trim().to_string(),
671                avatar: Some(post_detail.user_img_url),
672            },
673            content,
674            create_time: Some(post_detail.create_time),
675            like_count: Some(post_detail.thumb_num),
676            replies,
677        }))
678    }
679
680    pub async fn reply_list(
681        &self,
682        reply_id: u32,
683        reply_num: u16,
684    ) -> Result<Option<Vec<ShortComment>>, Error> {
685        let response: ReplyListResponse = self
686            .get_query(
687                "/reply/getReplyList",
688                ReplyListRequest {
689                    reply_id,
690                    page_size: reply_num,
691                },
692            )
693            .await?;
694        utils::check_response_success(response.code, response.msg, response.ok)?;
695
696        let mut result = Vec::with_capacity(reply_num as usize);
697
698        for reply in response.data.unwrap().list.unwrap() {
699            let Some(mut content) = super::parse_multi_line(reply.reply_content) else {
700                continue;
701            };
702
703            if reply.last_nick_name.is_some() {
704                content
705                    .first_mut()
706                    .unwrap()
707                    .insert_str(0, &format!("回复@{}  ", reply.last_nick_name.unwrap()));
708            }
709
710            result.push(ShortComment {
711                id: reply.reply_id,
712                user: UserInfo {
713                    nickname: reply.nick_name.trim().to_string(),
714                    avatar: Some(reply.user_img_url),
715                },
716                content,
717                create_time: Some(reply.create_time),
718                like_count: Some(reply.thumb_num),
719                replies: None,
720            });
721        }
722
723        if result.is_empty() {
724            Ok(None)
725        } else {
726            result.sort_unstable_by_key(|x| x.create_time.unwrap());
727            result.dedup();
728            Ok(Some(result))
729        }
730    }
731
732    async fn do_search_with_keyword(
733        &self,
734        option: &Options,
735        page: u16,
736        size: u16,
737    ) -> Result<Option<Vec<u32>>, Error> {
738        let (start_word, end_word) = CiyuanjiClient::to_word(option);
739        let (first_classify, _) = CiyuanjiClient::to_classify_ids(option);
740
741        let response: SearchBookListResponse = self
742            .get_query(
743                "/book/searchBookList",
744                SearchBookListRequest {
745                    page_no: page + 1,
746                    page_size: size,
747                    // 0 按推荐
748                    // 1 按人气
749                    // 2 按销量
750                    // 3 按更新
751                    rank_type: "0",
752                    keyword: option.keyword.as_ref().unwrap().to_string(),
753                    is_fee: CiyuanjiClient::to_is_fee(option),
754                    end_state: CiyuanjiClient::to_end_state(option),
755                    start_word,
756                    end_word,
757                    classify_ids: first_classify,
758                },
759            )
760            .await?;
761        utils::check_response_success(response.code, response.msg, response.ok)?;
762        let es_book_list = response.data.unwrap().es_book_list.unwrap();
763
764        if es_book_list.is_empty() {
765            return Ok(None);
766        }
767
768        let mut result = Vec::new();
769        let sys_tags = self.tags().await?;
770
771        for novel_info in es_book_list {
772            let mut tag_ids = Vec::new();
773
774            if novel_info.tag_name.is_some() {
775                let tag_names: Vec<_> = novel_info
776                    .tag_name
777                    .unwrap()
778                    .split(',')
779                    .map(|x| x.trim().to_string())
780                    .filter(|x| !x.is_empty())
781                    .collect();
782
783                for tag_name in tag_names {
784                    if let Some(tag) = sys_tags.iter().find(|x| x.name == tag_name) {
785                        tag_ids.push(tag.id.unwrap());
786                    }
787                }
788            }
789
790            if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
791                && CiyuanjiClient::match_tags(option, &tag_ids)
792                && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
793                && CiyuanjiClient::match_category(
794                    option,
795                    novel_info.first_classify,
796                    novel_info.second_classify,
797                )
798            {
799                result.push(novel_info.book_id);
800            }
801        }
802
803        Ok(Some(result))
804    }
805
806    async fn do_search_without_keyword(
807        &self,
808        option: &Options,
809        page: u16,
810        size: u16,
811    ) -> Result<Option<Vec<u32>>, Error> {
812        let (start_word, end_word) = CiyuanjiClient::to_word(option);
813        let (first_classify, second_classify) = CiyuanjiClient::to_classify_ids(option);
814
815        let response: BookListResponse = self
816            .get_query(
817                "/book/getBookListByParams",
818                BookListRequest {
819                    page_no: page + 1,
820                    page_size: size,
821                    // 1 人气最高
822                    // 2 订阅最多
823                    // 3 最近更新
824                    // 4 最近上架
825                    // 6 最近新书
826                    rank_type: "1",
827                    first_classify,
828                    second_classify,
829                    start_word,
830                    end_word,
831                    is_fee: CiyuanjiClient::to_is_fee(option),
832                    end_state: CiyuanjiClient::to_end_state(option),
833                },
834            )
835            .await?;
836        utils::check_response_success(response.code, response.msg, response.ok)?;
837        let book_list = response.data.unwrap().book_list.unwrap();
838
839        if book_list.is_empty() {
840            return Ok(None);
841        }
842
843        let mut result = Vec::new();
844        for novel_info in book_list {
845            let mut tag_ids = Vec::new();
846            if novel_info.tag_list.is_some() {
847                for tags in novel_info.tag_list.unwrap() {
848                    tag_ids.push(tags.tag_id);
849                }
850            }
851
852            if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
853                && CiyuanjiClient::match_tags(option, &tag_ids)
854                && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
855            {
856                result.push(novel_info.book_id);
857            }
858        }
859
860        Ok(Some(result))
861    }
862
863    fn to_end_state(option: &Options) -> Option<String> {
864        option.is_finished.map(|x| {
865            if x {
866                String::from("1")
867            } else {
868                String::from("2")
869            }
870        })
871    }
872
873    fn to_is_fee(option: &Options) -> Option<String> {
874        option.is_vip.map(|x| {
875            if x {
876                String::from("1")
877            } else {
878                String::from("0")
879            }
880        })
881    }
882
883    fn to_word(option: &Options) -> (Option<String>, Option<String>) {
884        let mut start_word = None;
885        let mut end_word = None;
886
887        if option.word_count.is_some() {
888            match option.word_count.as_ref().unwrap() {
889                WordCountRange::Range(range) => {
890                    start_word = Some(range.start.to_string());
891                    end_word = Some(range.end.to_string());
892                }
893                WordCountRange::RangeFrom(range_from) => {
894                    start_word = Some(range_from.start.to_string())
895                }
896                WordCountRange::RangeTo(range_to) => end_word = Some(range_to.end.to_string()),
897            }
898        }
899
900        (start_word, end_word)
901    }
902
903    fn to_classify_ids(option: &Options) -> (Option<String>, Option<String>) {
904        let mut first_classify = None;
905        let mut second_classify = None;
906
907        if option.category.is_some() {
908            let category = option.category.as_ref().unwrap();
909
910            if category.parent_id.is_some() {
911                first_classify = category.parent_id.map(|x| x.to_string());
912                second_classify = category.id.map(|x| x.to_string());
913            } else {
914                first_classify = category.id.map(|x| x.to_string());
915            }
916        }
917
918        (first_classify, second_classify)
919    }
920
921    fn match_update_days(option: &Options, update_time: Option<NaiveDateTime>) -> bool {
922        if option.update_days.is_none() || update_time.is_none() {
923            return true;
924        }
925
926        let other_time = Shanghai.from_local_datetime(&update_time.unwrap()).unwrap()
927            + Duration::try_days(*option.update_days.as_ref().unwrap() as i64).unwrap();
928
929        Local::now() <= other_time
930    }
931
932    fn match_category(
933        option: &Options,
934        first_classify: Option<u16>,
935        second_classify: Option<u16>,
936    ) -> bool {
937        if option.category.is_none() {
938            return true;
939        }
940
941        let category = option.category.as_ref().unwrap();
942
943        if category.parent_id.is_some() {
944            category.id == second_classify && category.parent_id == first_classify
945        } else {
946            category.id == first_classify
947        }
948    }
949
950    fn match_tags(option: &Options, tag_ids: &[u16]) -> bool {
951        if option.tags.is_none() {
952            return true;
953        }
954
955        option
956            .tags
957            .as_ref()
958            .unwrap()
959            .iter()
960            .all(|tag| tag_ids.contains(tag.id.as_ref().unwrap()))
961    }
962
963    fn match_excluded_tags(option: &Options, tag_ids: &[u16]) -> bool {
964        if option.excluded_tags.is_none() {
965            return true;
966        }
967
968        tag_ids.iter().all(|id| {
969            !option
970                .excluded_tags
971                .as_ref()
972                .unwrap()
973                .iter()
974                .any(|tag| tag.id.unwrap() == *id)
975        })
976    }
977
978    fn parse_word_count(word_count: i32) -> Option<u32> {
979        // Some novels have negative word counts, e.g. 9326
980        if word_count <= 0 {
981            None
982        } else {
983            Some(word_count as u32)
984        }
985    }
986
987    async fn parse_tags(&self, tag_list: Vec<BookTag>) -> Result<Option<Vec<Tag>>, Error> {
988        let sys_tags = self.tags().await?;
989
990        let mut result = Vec::new();
991        for tag in tag_list {
992            let name = tag.tag_name.trim().to_string();
993
994            // Remove non-system tags
995            if sys_tags.iter().any(|item| item.name == name) {
996                result.push(Tag {
997                    id: Some(tag.tag_id),
998                    name,
999                });
1000            } else {
1001                tracing::info!(
1002                    "This tag is not a system tag and is ignored: {name}({})",
1003                    tag.tag_id
1004                );
1005            }
1006        }
1007
1008        if result.is_empty() {
1009            Ok(None)
1010        } else {
1011            result.sort_unstable_by_key(|x| x.id.unwrap());
1012            Ok(Some(result))
1013        }
1014    }
1015
1016    fn parse_image_url(line: &str) -> Option<Url> {
1017        let begin = line.find("http").unwrap();
1018        let end = line.find("[/img]").unwrap();
1019
1020        let url = line
1021            .chars()
1022            .skip(begin)
1023            .take(end - begin)
1024            .collect::<String>()
1025            .trim()
1026            .to_string();
1027
1028        match Url::parse(&url) {
1029            Ok(url) => Some(url),
1030            Err(error) => {
1031                tracing::error!("Image URL parse failed: {error}, content: {line}");
1032                None
1033            }
1034        }
1035    }
1036
1037    async fn get_tags(&self, book_type: u16, result: &mut Vec<Tag>) -> Result<(), Error> {
1038        let response: TagsResponse = self
1039            .get_query(
1040                "/tag/getAppTagList",
1041                TagsRequest {
1042                    page_no: 1,
1043                    page_size: 99,
1044                    book_type,
1045                },
1046            )
1047            .await?;
1048        utils::check_response_success(response.code, response.msg, response.ok)?;
1049
1050        for tag in response.data.unwrap().list.unwrap() {
1051            result.push(Tag {
1052                id: Some(tag.tag_id),
1053                name: tag.tag_name.trim().to_string(),
1054            });
1055        }
1056
1057        Ok(())
1058    }
1059
1060    async fn get_categories(
1061        &self,
1062        book_type: &'static str,
1063        result: &mut Vec<Category>,
1064    ) -> Result<(), Error> {
1065        let response: CategoryResponse = self
1066            .get_query(
1067                "/classify/getBookClassifyListByParams",
1068                CategoryRequest {
1069                    page_no: 1,
1070                    page_size: 99,
1071                    book_type,
1072                },
1073            )
1074            .await?;
1075        utils::check_response_success(response.code, response.msg, response.ok)?;
1076
1077        for category in response.data.unwrap().classify_list.unwrap() {
1078            let basic_id = category.classify_id;
1079            let basic_name = category.classify_name.trim().to_string();
1080
1081            for child_category in category.child_list {
1082                result.push(Category {
1083                    id: Some(child_category.classify_id),
1084                    parent_id: Some(basic_id),
1085                    name: format!("{basic_name}-{}", child_category.classify_name.trim()),
1086                });
1087            }
1088
1089            result.push(Category {
1090                id: Some(basic_id),
1091                parent_id: None,
1092                name: basic_name,
1093            });
1094        }
1095
1096        Ok(())
1097    }
1098}