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#[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 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 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 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 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 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 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: 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 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 view_type: "2",
460 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 view_type: "2",
486 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 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 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 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 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 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 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}