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 content_infos_multiple(
441        &self,
442        infos: &[ChapterInfo],
443    ) -> Result<Vec<ContentInfos>, Error> {
444        let mut result = Vec::new();
445
446        for info in infos {
447            result.push(self.content_infos(info).await?);
448        }
449
450        Ok(result)
451    }
452
453    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
454        let response: GenericResponse = self
455            .post(
456                "/order/consume",
457                OrderChapterRequest {
458                    // always 2
459                    view_type: "2",
460                    // always 1
461                    consume_type: "1",
462                    book_id: info.novel_id.unwrap().to_string(),
463                    product_id: info.id.to_string(),
464                    buy_count: "1",
465                },
466            )
467            .await?;
468        if utils::check_already_ordered(&response.code, &response.msg) {
469            tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
470        } else {
471            utils::check_response_success(response.code, response.msg, response.ok)?;
472        }
473
474        Ok(())
475    }
476
477    async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
478        assert!(id > 0);
479
480        let response: GenericResponse = self
481            .post(
482                "/order/consume",
483                OrderNovelRequest {
484                    // always 2
485                    view_type: "2",
486                    // always 1
487                    consume_type: "1",
488                    book_id: id.to_string(),
489                    is_all_unpaid: "1",
490                },
491            )
492            .await?;
493        if utils::check_already_ordered(&response.code, &response.msg) {
494            tracing::info!("{}", CiyuanjiClient::ALREADY_ORDERED_MSG);
495        } else {
496            utils::check_response_success(response.code, response.msg, response.ok)?;
497        }
498
499        Ok(())
500    }
501
502    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
503        match self.db().await?.find_image(url).await? {
504            FindImageResult::Ok(image) => Ok(image),
505            FindImageResult::None => {
506                let response = self.get_rss(url).await?;
507                let bytes = response.bytes().await?;
508
509                let image = ImageReader::new(Cursor::new(&bytes))
510                    .with_guessed_format()?
511                    .decode()?;
512
513                self.db().await?.insert_image(url, bytes).await?;
514
515                Ok(image)
516            }
517        }
518    }
519
520    async fn categories(&self) -> Result<&Vec<Category>, Error> {
521        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
522
523        CATEGORIES
524            .get_or_try_init(|| async {
525                let mut result = Vec::with_capacity(32);
526
527                // 1 男生
528                // 2 漫画
529                // 4 女生
530                self.get_categories("1", &mut result).await?;
531                self.get_categories("4", &mut result).await?;
532
533                result.sort_unstable_by_key(|x| x.id.unwrap());
534                result.dedup();
535                Ok(result)
536            })
537            .await
538    }
539
540    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
541        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
542
543        TAGS.get_or_try_init(|| async {
544            let mut result = Vec::with_capacity(64);
545
546            self.get_tags(1, &mut result).await?;
547            self.get_tags(4, &mut result).await?;
548
549            result.push(Tag {
550                id: Some(17),
551                name: String::from("无限流"),
552            });
553            result.push(Tag {
554                id: Some(19),
555                name: String::from("后宫"),
556            });
557            result.push(Tag {
558                id: Some(26),
559                name: String::from("变身"),
560            });
561            result.push(Tag {
562                id: Some(30),
563                name: String::from("百合"),
564            });
565            result.push(Tag {
566                id: Some(96),
567                name: String::from("变百"),
568            });
569            result.push(Tag {
570                id: Some(127),
571                name: String::from("性转"),
572            });
573            result.push(Tag {
574                id: Some(249),
575                name: String::from("吸血鬼"),
576            });
577            result.push(Tag {
578                id: Some(570),
579                name: String::from("纯百"),
580            });
581            result.push(Tag {
582                id: Some(1431),
583                name: String::from("复仇"),
584            });
585            result.push(Tag {
586                id: Some(1512),
587                name: String::from("魔幻"),
588            });
589            result.push(Tag {
590                id: Some(5793),
591                name: String::from("少女"),
592            });
593
594            result.sort_unstable_by_key(|x| x.id.unwrap());
595            result.dedup();
596            Ok(result)
597        })
598        .await
599    }
600
601    async fn search_infos(
602        &self,
603        option: &Options,
604        page: u16,
605        size: u16,
606    ) -> Result<Option<Vec<u32>>, Error> {
607        if option.keyword.is_some() {
608            self.do_search_with_keyword(option, page, size).await
609        } else {
610            self.do_search_without_keyword(option, page, size).await
611        }
612    }
613
614    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
615        match comment_type {
616            CommentType::Short => true,
617            CommentType::Long => false,
618        }
619    }
620}
621
622impl CiyuanjiClient {
623    pub async fn post_detail(&self, post_id: u32) -> Result<Option<ShortComment>, Error> {
624        let response: PostDetailResponse = self
625            .get_query(
626                "/apppost/getApppostDetail",
627                PostDetailRequest {
628                    post_id,
629                    page_no: 1,
630                    page_size: 9999,
631                    // 2 最新
632                    // 3 最热
633                    rank_type: 2,
634                },
635            )
636            .await?;
637        utils::check_response_success(response.code, response.msg, response.ok)?;
638
639        let post_detail = response.data.unwrap();
640        let Some(content) = super::parse_multi_line(post_detail.post_content) else {
641            return Ok(None);
642        };
643
644        let replies = if !post_detail.list.is_empty() {
645            let mut replies = Vec::with_capacity(post_detail.list.len());
646
647            for reply in post_detail.list {
648                let Some(content) = super::parse_multi_line(reply.reply_content) else {
649                    continue;
650                };
651
652                replies.push(ShortComment {
653                    id: reply.reply_id,
654                    user: UserInfo {
655                        nickname: reply.nick_name.trim().to_string(),
656                        avatar: Some(reply.user_img_url),
657                    },
658                    content,
659                    create_time: Some(reply.create_time),
660                    like_count: Some(reply.thumb_num),
661                    replies: if reply.reply_num == 0 {
662                        None
663                    } else {
664                        self.reply_list(reply.reply_id, reply.reply_num).await?
665                    },
666                });
667            }
668
669            if replies.is_empty() {
670                None
671            } else {
672                replies.sort_unstable_by_key(|x| x.create_time.unwrap());
673                replies.dedup();
674                Some(replies)
675            }
676        } else {
677            None
678        };
679
680        Ok(Some(ShortComment {
681            id: post_id,
682            user: UserInfo {
683                nickname: post_detail.nick_name.trim().to_string(),
684                avatar: Some(post_detail.user_img_url),
685            },
686            content,
687            create_time: Some(post_detail.create_time),
688            like_count: Some(post_detail.thumb_num),
689            replies,
690        }))
691    }
692
693    pub async fn reply_list(
694        &self,
695        reply_id: u32,
696        reply_num: u16,
697    ) -> Result<Option<Vec<ShortComment>>, Error> {
698        let response: ReplyListResponse = self
699            .get_query(
700                "/reply/getReplyList",
701                ReplyListRequest {
702                    reply_id,
703                    page_size: reply_num,
704                },
705            )
706            .await?;
707        utils::check_response_success(response.code, response.msg, response.ok)?;
708
709        let mut result = Vec::with_capacity(reply_num as usize);
710
711        for reply in response.data.unwrap().list.unwrap() {
712            let Some(mut content) = super::parse_multi_line(reply.reply_content) else {
713                continue;
714            };
715
716            if reply.last_nick_name.is_some() {
717                content
718                    .first_mut()
719                    .unwrap()
720                    .insert_str(0, &format!("回复@{}  ", reply.last_nick_name.unwrap()));
721            }
722
723            result.push(ShortComment {
724                id: reply.reply_id,
725                user: UserInfo {
726                    nickname: reply.nick_name.trim().to_string(),
727                    avatar: Some(reply.user_img_url),
728                },
729                content,
730                create_time: Some(reply.create_time),
731                like_count: Some(reply.thumb_num),
732                replies: None,
733            });
734        }
735
736        if result.is_empty() {
737            Ok(None)
738        } else {
739            result.sort_unstable_by_key(|x| x.create_time.unwrap());
740            result.dedup();
741            Ok(Some(result))
742        }
743    }
744
745    async fn do_search_with_keyword(
746        &self,
747        option: &Options,
748        page: u16,
749        size: u16,
750    ) -> Result<Option<Vec<u32>>, Error> {
751        let (start_word, end_word) = CiyuanjiClient::to_word(option);
752        let (first_classify, _) = CiyuanjiClient::to_classify_ids(option);
753
754        let response: SearchBookListResponse = self
755            .get_query(
756                "/book/searchBookList",
757                SearchBookListRequest {
758                    page_no: page + 1,
759                    page_size: size,
760                    // 0 按推荐
761                    // 1 按人气
762                    // 2 按销量
763                    // 3 按更新
764                    rank_type: "0",
765                    keyword: option.keyword.as_ref().unwrap().to_string(),
766                    is_fee: CiyuanjiClient::to_is_fee(option),
767                    end_state: CiyuanjiClient::to_end_state(option),
768                    start_word,
769                    end_word,
770                    classify_ids: first_classify,
771                },
772            )
773            .await?;
774        utils::check_response_success(response.code, response.msg, response.ok)?;
775        let es_book_list = response.data.unwrap().es_book_list.unwrap();
776
777        if es_book_list.is_empty() {
778            return Ok(None);
779        }
780
781        let mut result = Vec::new();
782        let sys_tags = self.tags().await?;
783
784        for novel_info in es_book_list {
785            let mut tag_ids = Vec::new();
786
787            if novel_info.tag_name.is_some() {
788                let tag_names: Vec<_> = novel_info
789                    .tag_name
790                    .unwrap()
791                    .split(',')
792                    .map(|x| x.trim().to_string())
793                    .filter(|x| !x.is_empty())
794                    .collect();
795
796                for tag_name in tag_names {
797                    if let Some(tag) = sys_tags.iter().find(|x| x.name == tag_name) {
798                        tag_ids.push(tag.id.unwrap());
799                    }
800                }
801            }
802
803            if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
804                && CiyuanjiClient::match_tags(option, &tag_ids)
805                && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
806                && CiyuanjiClient::match_category(
807                    option,
808                    novel_info.first_classify,
809                    novel_info.second_classify,
810                )
811            {
812                result.push(novel_info.book_id);
813            }
814        }
815
816        Ok(Some(result))
817    }
818
819    async fn do_search_without_keyword(
820        &self,
821        option: &Options,
822        page: u16,
823        size: u16,
824    ) -> Result<Option<Vec<u32>>, Error> {
825        let (start_word, end_word) = CiyuanjiClient::to_word(option);
826        let (first_classify, second_classify) = CiyuanjiClient::to_classify_ids(option);
827
828        let response: BookListResponse = self
829            .get_query(
830                "/book/getBookListByParams",
831                BookListRequest {
832                    page_no: page + 1,
833                    page_size: size,
834                    // 1 人气最高
835                    // 2 订阅最多
836                    // 3 最近更新
837                    // 4 最近上架
838                    // 6 最近新书
839                    rank_type: "1",
840                    first_classify,
841                    second_classify,
842                    start_word,
843                    end_word,
844                    is_fee: CiyuanjiClient::to_is_fee(option),
845                    end_state: CiyuanjiClient::to_end_state(option),
846                },
847            )
848            .await?;
849        utils::check_response_success(response.code, response.msg, response.ok)?;
850        let book_list = response.data.unwrap().book_list.unwrap();
851
852        if book_list.is_empty() {
853            return Ok(None);
854        }
855
856        let mut result = Vec::new();
857        for novel_info in book_list {
858            let mut tag_ids = Vec::new();
859            if novel_info.tag_list.is_some() {
860                for tags in novel_info.tag_list.unwrap() {
861                    tag_ids.push(tags.tag_id);
862                }
863            }
864
865            if CiyuanjiClient::match_update_days(option, novel_info.latest_update_time)
866                && CiyuanjiClient::match_tags(option, &tag_ids)
867                && CiyuanjiClient::match_excluded_tags(option, &tag_ids)
868            {
869                result.push(novel_info.book_id);
870            }
871        }
872
873        Ok(Some(result))
874    }
875
876    fn to_end_state(option: &Options) -> Option<String> {
877        option.is_finished.map(|x| {
878            if x {
879                String::from("1")
880            } else {
881                String::from("2")
882            }
883        })
884    }
885
886    fn to_is_fee(option: &Options) -> Option<String> {
887        option.is_vip.map(|x| {
888            if x {
889                String::from("1")
890            } else {
891                String::from("0")
892            }
893        })
894    }
895
896    fn to_word(option: &Options) -> (Option<String>, Option<String>) {
897        let mut start_word = None;
898        let mut end_word = None;
899
900        if option.word_count.is_some() {
901            match option.word_count.as_ref().unwrap() {
902                WordCountRange::Range(range) => {
903                    start_word = Some(range.start.to_string());
904                    end_word = Some(range.end.to_string());
905                }
906                WordCountRange::RangeFrom(range_from) => {
907                    start_word = Some(range_from.start.to_string())
908                }
909                WordCountRange::RangeTo(range_to) => end_word = Some(range_to.end.to_string()),
910            }
911        }
912
913        (start_word, end_word)
914    }
915
916    fn to_classify_ids(option: &Options) -> (Option<String>, Option<String>) {
917        let mut first_classify = None;
918        let mut second_classify = None;
919
920        if option.category.is_some() {
921            let category = option.category.as_ref().unwrap();
922
923            if category.parent_id.is_some() {
924                first_classify = category.parent_id.map(|x| x.to_string());
925                second_classify = category.id.map(|x| x.to_string());
926            } else {
927                first_classify = category.id.map(|x| x.to_string());
928            }
929        }
930
931        (first_classify, second_classify)
932    }
933
934    fn match_update_days(option: &Options, update_time: Option<NaiveDateTime>) -> bool {
935        if option.update_days.is_none() || update_time.is_none() {
936            return true;
937        }
938
939        let other_time = Shanghai.from_local_datetime(&update_time.unwrap()).unwrap()
940            + Duration::try_days(*option.update_days.as_ref().unwrap() as i64).unwrap();
941
942        Local::now() <= other_time
943    }
944
945    fn match_category(
946        option: &Options,
947        first_classify: Option<u16>,
948        second_classify: Option<u16>,
949    ) -> bool {
950        if option.category.is_none() {
951            return true;
952        }
953
954        let category = option.category.as_ref().unwrap();
955
956        if category.parent_id.is_some() {
957            category.id == second_classify && category.parent_id == first_classify
958        } else {
959            category.id == first_classify
960        }
961    }
962
963    fn match_tags(option: &Options, tag_ids: &[u16]) -> bool {
964        if option.tags.is_none() {
965            return true;
966        }
967
968        option
969            .tags
970            .as_ref()
971            .unwrap()
972            .iter()
973            .all(|tag| tag_ids.contains(tag.id.as_ref().unwrap()))
974    }
975
976    fn match_excluded_tags(option: &Options, tag_ids: &[u16]) -> bool {
977        if option.excluded_tags.is_none() {
978            return true;
979        }
980
981        tag_ids.iter().all(|id| {
982            !option
983                .excluded_tags
984                .as_ref()
985                .unwrap()
986                .iter()
987                .any(|tag| tag.id.unwrap() == *id)
988        })
989    }
990
991    fn parse_word_count(word_count: i32) -> Option<u32> {
992        // Some novels have negative word counts, e.g. 9326
993        if word_count <= 0 {
994            None
995        } else {
996            Some(word_count as u32)
997        }
998    }
999
1000    async fn parse_tags(&self, tag_list: Vec<BookTag>) -> Result<Option<Vec<Tag>>, Error> {
1001        let sys_tags = self.tags().await?;
1002
1003        let mut result = Vec::new();
1004        for tag in tag_list {
1005            let name = tag.tag_name.trim().to_string();
1006
1007            // Remove non-system tags
1008            if sys_tags.iter().any(|item| item.name == name) {
1009                result.push(Tag {
1010                    id: Some(tag.tag_id),
1011                    name,
1012                });
1013            } else {
1014                tracing::info!(
1015                    "This tag is not a system tag and is ignored: {name}({})",
1016                    tag.tag_id
1017                );
1018            }
1019        }
1020
1021        if result.is_empty() {
1022            Ok(None)
1023        } else {
1024            result.sort_unstable_by_key(|x| x.id.unwrap());
1025            Ok(Some(result))
1026        }
1027    }
1028
1029    fn parse_image_url(line: &str) -> Option<Url> {
1030        let begin = line.find("http").unwrap();
1031        let end = line.find("[/img]").unwrap();
1032
1033        let url = line
1034            .chars()
1035            .skip(begin)
1036            .take(end - begin)
1037            .collect::<String>()
1038            .trim()
1039            .to_string();
1040
1041        match Url::parse(&url) {
1042            Ok(url) => Some(url),
1043            Err(error) => {
1044                tracing::error!("Image URL parse failed: {error}, content: {line}");
1045                None
1046            }
1047        }
1048    }
1049
1050    async fn get_tags(&self, book_type: u16, result: &mut Vec<Tag>) -> Result<(), Error> {
1051        let response: TagsResponse = self
1052            .get_query(
1053                "/tag/getAppTagList",
1054                TagsRequest {
1055                    page_no: 1,
1056                    page_size: 99,
1057                    book_type,
1058                },
1059            )
1060            .await?;
1061        utils::check_response_success(response.code, response.msg, response.ok)?;
1062
1063        for tag in response.data.unwrap().list.unwrap() {
1064            result.push(Tag {
1065                id: Some(tag.tag_id),
1066                name: tag.tag_name.trim().to_string(),
1067            });
1068        }
1069
1070        Ok(())
1071    }
1072
1073    async fn get_categories(
1074        &self,
1075        book_type: &'static str,
1076        result: &mut Vec<Category>,
1077    ) -> Result<(), Error> {
1078        let response: CategoryResponse = self
1079            .get_query(
1080                "/classify/getBookClassifyListByParams",
1081                CategoryRequest {
1082                    page_no: 1,
1083                    page_size: 99,
1084                    book_type,
1085                },
1086            )
1087            .await?;
1088        utils::check_response_success(response.code, response.msg, response.ok)?;
1089
1090        for category in response.data.unwrap().classify_list.unwrap() {
1091            let basic_id = category.classify_id;
1092            let basic_name = category.classify_name.trim().to_string();
1093
1094            for child_category in category.child_list {
1095                result.push(Category {
1096                    id: Some(child_category.classify_id),
1097                    parent_id: Some(basic_id),
1098                    name: format!("{basic_name}-{}", child_category.classify_name.trim()),
1099                });
1100            }
1101
1102            result.push(Category {
1103                id: Some(basic_id),
1104                parent_id: None,
1105                name: basic_name,
1106            });
1107        }
1108
1109        Ok(())
1110    }
1111}