novel_api/sfacg/
mod.rs

1mod structure;
2mod utils;
3
4use std::{io::Cursor, path::PathBuf};
5
6use chrono::{DateTime, Utc};
7use chrono_tz::{Asia::Shanghai, Tz};
8use image::{DynamicImage, ImageReader};
9use tokio::sync::OnceCell;
10use url::Url;
11
12use self::structure::*;
13use crate::{
14    Category, ChapterInfo, Client, Comment, CommentType, ContentInfo, ContentInfos, Error,
15    FindImageResult, FindTextResult, HTTPClient, LongComment, NovelDB, NovelInfo, Options,
16    ShortComment, Tag, UserInfo, VolumeInfo, VolumeInfos, WordCountRange,
17};
18
19/// Sfacg client, use it to access Apis
20#[must_use]
21pub struct SfacgClient {
22    proxy: Option<Url>,
23    no_proxy: bool,
24    cert_path: Option<PathBuf>,
25
26    client: OnceCell<HTTPClient>,
27    client_rss: OnceCell<HTTPClient>,
28
29    db: OnceCell<NovelDB>,
30}
31
32impl Client for SfacgClient {
33    fn proxy(&mut self, proxy: Url) {
34        self.proxy = Some(proxy);
35    }
36
37    fn no_proxy(&mut self) {
38        self.no_proxy = true;
39    }
40
41    fn cert(&mut self, cert_path: PathBuf) {
42        self.cert_path = Some(cert_path);
43    }
44
45    async fn shutdown(&self) -> Result<(), Error> {
46        self.client().await?.shutdown()
47    }
48
49    async fn add_cookie(&self, cookie_str: &str, url: &Url) -> Result<(), Error> {
50        self.client().await?.add_cookie(cookie_str, url)
51    }
52
53    async fn log_in(&self, username: String, password: Option<String>) -> Result<(), Error> {
54        assert!(!username.is_empty());
55        assert!(password.is_some());
56
57        let password = password.unwrap();
58
59        let response = self
60            .post("/sessions", LogInRequest { username, password })
61            .await?
62            .json::<GenericResponse>()
63            .await?;
64        response.status.check()?;
65
66        Ok(())
67    }
68
69    async fn logged_in(&self) -> Result<bool, Error> {
70        let response = self.get("/user").await?.json::<GenericResponse>().await?;
71
72        if response.status.unauthorized() {
73            Ok(false)
74        } else {
75            response.status.check()?;
76            Ok(true)
77        }
78    }
79
80    async fn user_info(&self) -> Result<UserInfo, Error> {
81        let response = self.get("/user").await?.json::<UserInfoResponse>().await?;
82        response.status.check()?;
83        let data = response.data.unwrap();
84
85        Ok(UserInfo {
86            nickname: data.nick_name.trim().to_string(),
87            avatar: Some(data.avatar),
88        })
89    }
90
91    async fn money(&self) -> Result<u32, Error> {
92        let response = self
93            .get("/user/money")
94            .await?
95            .json::<MoneyResponse>()
96            .await?;
97        response.status.check()?;
98        let data = response.data.unwrap();
99
100        Ok(data.fire_money_remain + data.coupons_remain)
101    }
102
103    async fn sign_in(&self) -> Result<(), Error> {
104        let now: DateTime<Tz> = Utc::now().with_timezone(&Shanghai);
105
106        let response = self
107            .put(
108                "/user/newSignInfo",
109                SignRequest {
110                    sign_date: now.format("%Y-%m-%d").to_string(),
111                },
112            )
113            .await?
114            .json::<GenericResponse>()
115            .await?;
116        if response.status.already_signed_in() {
117            tracing::info!("{}", response.status.msg.unwrap().trim())
118        } else {
119            response.status.check()?;
120        }
121
122        Ok(())
123    }
124
125    async fn bookshelf_infos(&self) -> Result<Vec<u32>, Error> {
126        let response = self
127            .get_query("/user/Pockets", BookshelfInfoRequest { expand: "novels" })
128            .await?
129            .json::<BookshelfInfoResponse>()
130            .await?;
131        response.status.check()?;
132        let data = response.data.unwrap();
133
134        let mut result = Vec::with_capacity(32);
135        for info in data {
136            if info.expand.is_some() {
137                let novels = info.expand.unwrap().novels;
138
139                if novels.is_some() {
140                    for novel_info in novels.unwrap() {
141                        result.push(novel_info.novel_id);
142                    }
143                }
144            }
145        }
146
147        Ok(result)
148    }
149
150    async fn novel_info(&self, id: u32) -> Result<Option<NovelInfo>, Error> {
151        assert!(id > 0 && id <= i32::MAX as u32);
152
153        let response = self
154            .get_query(
155                format!("/novels/{id}"),
156                NovelInfoRequest {
157                    expand: "intro,typeName,sysTags",
158                },
159            )
160            .await?
161            .json::<NovelInfoResponse>()
162            .await?;
163        if response.status.not_found() {
164            return Ok(None);
165        }
166        response.status.check()?;
167        let data = response.data.unwrap();
168
169        let category = Category {
170            id: Some(data.type_id),
171            parent_id: None,
172            name: data.expand.type_name.trim().to_string(),
173        };
174
175        let novel_info = NovelInfo {
176            id,
177            name: data.novel_name.trim().to_string(),
178            author_name: data.author_name.trim().to_string(),
179            cover_url: Some(data.novel_cover),
180            introduction: super::parse_multi_line(data.expand.intro),
181            word_count: SfacgClient::parse_word_count(data.char_count),
182            is_vip: Some(data.sign_status == "VIP"),
183            is_finished: Some(data.is_finish),
184            create_time: Some(data.add_time),
185            update_time: Some(data.last_update_time),
186            category: Some(category),
187            tags: self.parse_tags(data.expand.sys_tags).await?,
188        };
189
190        Ok(Some(novel_info))
191    }
192
193    async fn comments(
194        &self,
195        id: u32,
196        comment_type: CommentType,
197        need_replies: bool,
198        page: u16,
199        size: u16,
200    ) -> Result<Option<Vec<Comment>>, Error> {
201        assert!(id <= i32::MAX as u32);
202
203        match comment_type {
204            CommentType::Short => self.do_short_comments(id, need_replies, page, size).await,
205            CommentType::Long => self.do_long_comments(id, need_replies, page, size).await,
206        }
207    }
208
209    async fn volume_infos(&self, id: u32) -> Result<Option<VolumeInfos>, Error> {
210        assert!(id <= i32::MAX as u32);
211
212        let response = self
213            .get(format!("/novels/{id}/dirs"))
214            .await?
215            .json::<VolumeInfosResponse>()
216            .await?;
217
218        if response.status.not_available() {
219            return Ok(None);
220        }
221
222        response.status.check()?;
223        let data = response.data.unwrap();
224
225        let mut volumes = VolumeInfos::with_capacity(8);
226        for volume in data.volume_list {
227            let mut volume_info = VolumeInfo {
228                id: volume.volume_id,
229                title: volume.title.trim().to_string(),
230                chapter_infos: Vec::with_capacity(volume.chapter_list.len()),
231            };
232
233            for chapter in volume.chapter_list {
234                let chapter_info = ChapterInfo {
235                    novel_id: Some(chapter.novel_id),
236                    id: chapter.chap_id,
237                    title: chapter.title.trim().to_string(),
238                    word_count: Some(chapter.char_count),
239                    create_time: Some(chapter.add_time),
240                    update_time: chapter.update_time,
241                    is_vip: Some(chapter.is_vip),
242                    price: Some(chapter.need_fire_money),
243                    payment_required: Some(chapter.need_fire_money != 0),
244                    is_valid: None,
245                };
246
247                volume_info.chapter_infos.push(chapter_info);
248            }
249
250            volumes.push(volume_info);
251        }
252
253        Ok(Some(volumes))
254    }
255
256    async fn content_infos(&self, info: &ChapterInfo) -> Result<ContentInfos, Error> {
257        let content;
258
259        match self.db().await?.find_text(info).await? {
260            FindTextResult::Ok(str) => {
261                content = str;
262            }
263            other => {
264                let response = self
265                    .get_query(
266                        format!("/Chaps/{}", info.id),
267                        ContentInfosRequest {
268                            expand: "content,isContentEncrypted",
269                        },
270                    )
271                    .await?
272                    .json::<ContentInfosResponse>()
273                    .await?;
274                response.status.check()?;
275                let data = response.data.unwrap();
276
277                if data.expand.is_content_encrypted {
278                    content = SfacgClient::convert(data.expand.content);
279                } else {
280                    content = data.expand.content;
281                }
282
283                if content.trim().is_empty() {
284                    return Err(Error::NovelApi(String::from("Content is empty")));
285                }
286
287                match other {
288                    FindTextResult::None => self.db().await?.insert_text(info, &content).await?,
289                    FindTextResult::Outdate => self.db().await?.update_text(info, &content).await?,
290                    FindTextResult::Ok(_) => (),
291                }
292            }
293        }
294
295        let mut content_infos = ContentInfos::with_capacity(128);
296        for line in content
297            .lines()
298            .map(|line| line.trim())
299            .filter(|line| !line.is_empty())
300        {
301            if line.starts_with("[img") {
302                match SfacgClient::parse_image_url(line) {
303                    Ok(url) => content_infos.push(ContentInfo::Image(url)),
304                    Err(err) => tracing::error!("{err}"),
305                }
306            } else {
307                content_infos.push(ContentInfo::Text(line.to_string()));
308            }
309        }
310
311        Ok(content_infos)
312    }
313
314    async fn order_chapter(&self, info: &ChapterInfo) -> Result<(), Error> {
315        let response = self
316            .post(
317                &format!("/novels/{}/orderedchaps", info.novel_id.unwrap()),
318                OrderRequest {
319                    order_all: false,
320                    auto_order: false,
321                    chap_ids: vec![info.id],
322                    order_type: "readOrder",
323                },
324            )
325            .await?
326            .json::<GenericResponse>()
327            .await?;
328        if response.status.already_ordered() {
329            tracing::info!("{}", response.status.msg.unwrap().trim())
330        } else {
331            response.status.check()?;
332        }
333
334        Ok(())
335    }
336
337    async fn order_novel(&self, id: u32, _: &VolumeInfos) -> Result<(), Error> {
338        assert!(id > 0 && id <= i32::MAX as u32);
339
340        let response = self
341            .post(
342                &format!("/novels/{id}/orderedchaps",),
343                OrderRequest {
344                    order_all: true,
345                    auto_order: false,
346                    chap_ids: vec![],
347                    order_type: "readOrder",
348                },
349            )
350            .await?
351            .json::<GenericResponse>()
352            .await?;
353        if response.status.already_ordered() {
354            tracing::info!("{}", response.status.msg.unwrap().trim())
355        } else {
356            response.status.check()?;
357        }
358
359        Ok(())
360    }
361
362    async fn image(&self, url: &Url) -> Result<DynamicImage, Error> {
363        match self.db().await?.find_image(url).await? {
364            FindImageResult::Ok(image) => Ok(image),
365            FindImageResult::None => {
366                let response = self.get_rss(url).await?;
367                let bytes = response.bytes().await?;
368
369                let image = ImageReader::new(Cursor::new(&bytes))
370                    .with_guessed_format()?
371                    .decode()?;
372
373                self.db().await?.insert_image(url, bytes).await?;
374
375                Ok(image)
376            }
377        }
378    }
379
380    async fn categories(&self) -> Result<&Vec<Category>, Error> {
381        static CATEGORIES: OnceCell<Vec<Category>> = OnceCell::const_new();
382
383        CATEGORIES
384            .get_or_try_init(|| async {
385                let response = self
386                    .get("/noveltypes")
387                    .await?
388                    .json::<CategoryResponse>()
389                    .await?;
390                response.status.check()?;
391                let data = response.data.unwrap();
392
393                let mut result = Vec::with_capacity(8);
394                for tag_data in data {
395                    result.push(Category {
396                        id: Some(tag_data.type_id),
397                        parent_id: None,
398                        name: tag_data.type_name.trim().to_string(),
399                    });
400                }
401
402                result.sort_unstable_by_key(|x| x.id.unwrap());
403
404                Ok(result)
405            })
406            .await
407    }
408
409    async fn tags(&self) -> Result<&Vec<Tag>, Error> {
410        static TAGS: OnceCell<Vec<Tag>> = OnceCell::const_new();
411
412        TAGS.get_or_try_init(|| async {
413            let response = self
414                .get("/novels/0/sysTags")
415                .await?
416                .json::<TagResponse>()
417                .await?;
418            response.status.check()?;
419            let data = response.data.unwrap();
420
421            let mut result = Vec::with_capacity(64);
422            for tag_data in data {
423                result.push(Tag {
424                    id: Some(tag_data.sys_tag_id),
425                    name: tag_data.tag_name.trim().to_string(),
426                });
427            }
428
429            // Tag that have been removed, but can still be used
430            result.push(Tag {
431                id: Some(74),
432                name: "百合".to_string(),
433            });
434
435            result.sort_unstable_by_key(|x| x.id.unwrap());
436
437            Ok(result)
438        })
439        .await
440    }
441
442    async fn search_infos(
443        &self,
444        option: &Options,
445        page: u16,
446        size: u16,
447    ) -> Result<Option<Vec<u32>>, Error> {
448        assert!(size <= 50, "The maximum number of items per page is 50");
449
450        if option.keyword.is_some() {
451            self.do_search_with_keyword(option, page, size).await
452        } else {
453            self.do_search_without_keyword(option, page, size).await
454        }
455    }
456
457    fn has_this_type_of_comments(comment_type: CommentType) -> bool {
458        match comment_type {
459            CommentType::Short => true,
460            CommentType::Long => true,
461        }
462    }
463}
464
465impl SfacgClient {
466    async fn do_short_comments(
467        &self,
468        id: u32,
469        need_replies: bool,
470        page: u16,
471        size: u16,
472    ) -> Result<Option<Vec<Comment>>, Error> {
473        assert!(size <= 50);
474
475        let response = self
476            .get_query(
477                format!("/novels/{id}/Cmts"),
478                ShortCommentRequest {
479                    page,
480                    size,
481                    r#type: "clear",
482                    sort: "smart",
483                },
484            )
485            .await?
486            .json::<CommentResponse>()
487            .await?;
488        response.status.check()?;
489        let data = response.data.unwrap();
490
491        if data.is_empty() {
492            return Ok(None);
493        }
494
495        let mut result = Vec::with_capacity(data.len());
496
497        for comment in data {
498            let Some(content) = super::parse_multi_line(comment.content) else {
499                continue;
500            };
501
502            result.push(Comment::Short(ShortComment {
503                id: comment.comment_id,
504                user: UserInfo {
505                    nickname: comment.display_name.trim().to_string(),
506                    avatar: Some(comment.avatar),
507                },
508                content,
509                create_time: Some(comment.create_time),
510                like_count: Some(comment.fav_count),
511                replies: if need_replies && comment.reply_num > 0 {
512                    self.comment_replies(comment.comment_id, CommentType::Short, comment.reply_num)
513                        .await?
514                } else {
515                    None
516                },
517            }));
518        }
519
520        Ok(Some(result))
521    }
522
523    async fn do_long_comments(
524        &self,
525        id: u32,
526        need_replies: bool,
527        page: u16,
528        size: u16,
529    ) -> Result<Option<Vec<Comment>>, Error> {
530        assert!(size <= 20);
531
532        let response = self
533            .get_query(
534                format!("/novels/{id}/lcmts"),
535                LongCommentRequest {
536                    page,
537                    size,
538                    charlen: 140,
539                    sort: "addtime",
540                },
541            )
542            .await?
543            .json::<CommentResponse>()
544            .await?;
545        response.status.check()?;
546        let data = response.data.unwrap();
547
548        if data.is_empty() {
549            return Ok(None);
550        }
551
552        let mut result = Vec::with_capacity(data.len());
553
554        for comment in data {
555            result.push(Comment::Long(LongComment {
556                id: comment.comment_id,
557                user: UserInfo {
558                    nickname: comment.display_name.trim().to_string(),
559                    avatar: Some(comment.avatar),
560                },
561                title: comment.title.unwrap().trim().to_string(),
562                content: self.long_comment_content(comment.comment_id).await?,
563                create_time: Some(comment.create_time),
564                like_count: Some(comment.fav_count),
565                replies: if need_replies && comment.reply_num > 0 {
566                    self.comment_replies(comment.comment_id, CommentType::Long, comment.reply_num)
567                        .await?
568                } else {
569                    None
570                },
571            }));
572        }
573
574        Ok(Some(result))
575    }
576
577    async fn long_comment_content(&self, comment_id: u32) -> Result<Vec<String>, Error> {
578        let response = self
579            .get(format!("/lcmts/{comment_id}"))
580            .await?
581            .json::<LongCommentContentResponse>()
582            .await?;
583        response.status.check()?;
584        let data = response.data.unwrap();
585
586        Ok(super::parse_multi_line(data.content).unwrap())
587    }
588
589    async fn comment_replies(
590        &self,
591        comment_id: u32,
592        comment_type: CommentType,
593        total: u16,
594    ) -> Result<Option<Vec<ShortComment>>, Error> {
595        let url = match comment_type {
596            CommentType::Short => format!("/cmts/{}/replys", comment_id),
597            CommentType::Long => format!("/lcmts/{}/replys", comment_id),
598        };
599
600        let mut page = 0;
601        let size = 50;
602        let total_page = if total % size == 0 {
603            total / size
604        } else {
605            total / size + 1
606        };
607        let mut reply_list = Vec::with_capacity(total as usize);
608
609        while page < total_page {
610            let response = self
611                .get_query(&url, ReplyRequest { page, size })
612                .await?
613                .json::<ReplyResponse>()
614                .await?;
615            response.status.check()?;
616            let data = response.data.unwrap();
617
618            for reply in data {
619                let Some(content) = super::parse_multi_line(reply.content) else {
620                    continue;
621                };
622
623                reply_list.push(ShortComment {
624                    id: reply.reply_id,
625                    user: UserInfo {
626                        nickname: reply.display_name.trim().to_string(),
627                        avatar: Some(reply.avatar),
628                    },
629                    content,
630                    create_time: Some(reply.create_time),
631                    like_count: None,
632                    replies: None,
633                });
634            }
635
636            page += 1;
637        }
638
639        if reply_list.is_empty() {
640            Ok(None)
641        } else {
642            reply_list.sort_unstable_by_key(|x| x.create_time.unwrap());
643            reply_list.dedup();
644            Ok(Some(reply_list))
645        }
646    }
647
648    async fn do_search_with_keyword(
649        &self,
650        option: &Options,
651        page: u16,
652        size: u16,
653    ) -> Result<Option<Vec<u32>>, Error> {
654        // 0 连载中
655        // 1 已完结
656        // -1 不限
657        let is_finish = if option.is_finished.is_none() {
658            -1
659        } else if *option.is_finished.as_ref().unwrap() {
660            1
661        } else {
662            0
663        };
664
665        // -1 不限
666        let update_days = if option.update_days.is_none() {
667            -1
668        } else {
669            option.update_days.unwrap() as i8
670        };
671
672        let response = self
673            .get_query(
674                "/search/novels/result/new",
675                SearchRequest {
676                    q: option.keyword.as_ref().unwrap().to_string(),
677                    is_finish,
678                    update_days,
679                    systagids: SfacgClient::tag_ids(&option.tags),
680                    page,
681                    size,
682                    // hot 人气最高
683                    // update 最新更新
684                    // marknum 收藏最高
685                    // ticket 月票最多
686                    // charcount 更新最多
687                    sort: "hot",
688                    expand: "sysTags",
689                },
690            )
691            .await?
692            .json::<SearchResponse>()
693            .await?;
694        response.status.check()?;
695        let data = response.data.unwrap();
696
697        if data.novels.is_empty() {
698            return Ok(None);
699        }
700
701        let mut result = Vec::new();
702        let sys_tags = self.tags().await?;
703
704        for novel_info in data.novels {
705            let mut tag_ids = vec![];
706
707            for tag in novel_info.expand.sys_tags {
708                if let Some(sys_tag) = sys_tags.iter().find(|x| x.id.unwrap() == tag.sys_tag_id) {
709                    tag_ids.push(sys_tag.id.unwrap());
710                }
711            }
712
713            if SfacgClient::match_category(option, novel_info.type_id)
714                && SfacgClient::match_excluded_tags(option, tag_ids)
715                && SfacgClient::match_vip(option, &novel_info.sign_status)
716                && SfacgClient::match_word_count(option, novel_info.char_count)
717            {
718                result.push(novel_info.novel_id);
719            }
720        }
721
722        Ok(Some(result))
723    }
724
725    async fn do_search_without_keyword(
726        &self,
727        option: &Options,
728        page: u16,
729        size: u16,
730    ) -> Result<Option<Vec<u32>>, Error> {
731        let mut category_id = 0;
732        if option.category.is_some() {
733            category_id = option.category.as_ref().unwrap().id.unwrap();
734        }
735
736        // -1 不限
737        let updatedays = if option.update_days.is_none() {
738            -1
739        } else {
740            option.update_days.unwrap() as i8
741        };
742
743        let isfinish = SfacgClient::bool_to_str(&option.is_finished);
744        let isfree = SfacgClient::bool_to_str(&option.is_vip.as_ref().map(|x| !x));
745
746        let systagids = SfacgClient::tag_ids(&option.tags);
747        let notexcludesystagids = SfacgClient::tag_ids(&option.excluded_tags);
748
749        let mut charcountbegin = 0;
750        let mut charcountend = 0;
751
752        if option.word_count.is_some() {
753            match option.word_count.as_ref().unwrap() {
754                WordCountRange::Range(range) => {
755                    charcountbegin = range.start;
756                    charcountend = range.end;
757                }
758                WordCountRange::RangeFrom(range_from) => charcountbegin = range_from.start,
759                WordCountRange::RangeTo(range_to) => charcountend = range_to.end,
760            }
761        }
762
763        let response = self
764            .get_query(
765                format!("/novels/{category_id}/sysTags/novels"),
766                NovelsRequest {
767                    charcountbegin,
768                    charcountend,
769                    isfinish,
770                    isfree,
771                    systagids,
772                    notexcludesystagids,
773                    updatedays,
774                    page,
775                    size,
776                    // latest 最新更新
777                    // viewtimes 人气最高
778                    // bookmark 收藏最高
779                    // ticket 月票最多
780                    // charcount 更新最多
781                    sort: "viewtimes",
782                },
783            )
784            .await?
785            .json::<NovelsResponse>()
786            .await?;
787        response.status.check()?;
788        let data = response.data.unwrap();
789
790        if data.is_empty() {
791            return Ok(None);
792        }
793
794        let mut result = Vec::new();
795        for novel_data in data {
796            result.push(novel_data.novel_id);
797        }
798
799        Ok(Some(result))
800    }
801
802    fn parse_word_count(word_count: i32) -> Option<u32> {
803        // Some novels have negative word counts
804        if word_count <= 0 {
805            None
806        } else {
807            Some(word_count as u32)
808        }
809    }
810
811    async fn parse_tags(&self, tag_list: Vec<NovelInfoSysTag>) -> Result<Option<Vec<Tag>>, Error> {
812        let sys_tags = self.tags().await?;
813
814        let mut result = Vec::new();
815        for tag in tag_list {
816            let id = tag.sys_tag_id;
817            let name = tag.tag_name.trim().to_string();
818
819            // Remove non-system tags
820            if sys_tags.iter().any(|sys_tag| sys_tag.id.unwrap() == id) {
821                result.push(Tag { id: Some(id), name });
822            } else {
823                tracing::info!("This tag is not a system tag and is ignored: {name}");
824            }
825        }
826
827        if result.is_empty() {
828            Ok(None)
829        } else {
830            result.sort_unstable_by_key(|x| x.id.unwrap());
831            Ok(Some(result))
832        }
833    }
834
835    fn parse_image_url(line: &str) -> Result<Url, Error> {
836        let begin = line.find("http");
837        let end = line.find("[/img]");
838
839        if begin.is_none() || end.is_none() {
840            return Err(Error::NovelApi(format!(
841                "Image URL format is incorrect: {line}"
842            )));
843        }
844
845        let begin = begin.unwrap();
846        let end = end.unwrap();
847
848        let url = line
849            .chars()
850            .skip(begin)
851            .take(end - begin)
852            .collect::<String>()
853            .trim()
854            .to_string();
855
856        match Url::parse(&url) {
857            Ok(url) => Ok(url),
858            Err(error) => Err(Error::NovelApi(format!(
859                "Image URL parse failed: {error}, content: {line}"
860            ))),
861        }
862    }
863
864    fn bool_to_str(flag: &Option<bool>) -> &'static str {
865        if flag.is_some() {
866            if *flag.as_ref().unwrap() {
867                "is"
868            } else {
869                "not"
870            }
871        } else {
872            "both"
873        }
874    }
875
876    fn tag_ids(tags: &Option<Vec<Tag>>) -> Option<String> {
877        tags.as_ref().map(|tags| {
878            tags.iter()
879                .map(|tag| tag.id.unwrap().to_string())
880                .collect::<Vec<String>>()
881                .join(",")
882        })
883    }
884
885    fn match_vip(option: &Options, sign_status: &str) -> bool {
886        if option.is_vip.is_none() {
887            return true;
888        }
889
890        if *option.is_vip.as_ref().unwrap() {
891            sign_status == "VIP"
892        } else {
893            sign_status != "VIP"
894        }
895    }
896
897    fn match_excluded_tags(option: &Options, tag_ids: Vec<u16>) -> bool {
898        if option.excluded_tags.is_none() {
899            return true;
900        }
901
902        tag_ids.iter().all(|id| {
903            !option
904                .excluded_tags
905                .as_ref()
906                .unwrap()
907                .iter()
908                .any(|tag| tag.id.unwrap() == *id)
909        })
910    }
911
912    fn match_category(option: &Options, category_id: u16) -> bool {
913        if option.category.is_none() {
914            return true;
915        }
916
917        let category = option.category.as_ref().unwrap();
918        category.id.unwrap() == category_id
919    }
920
921    fn match_word_count(option: &Options, word_count: i32) -> bool {
922        if option.word_count.is_none() {
923            return true;
924        }
925
926        if word_count <= 0 {
927            return true;
928        }
929
930        let word_count = word_count as u32;
931        match option.word_count.as_ref().unwrap() {
932            WordCountRange::Range(range) => {
933                if word_count >= range.start && word_count < range.end {
934                    return true;
935                }
936            }
937            WordCountRange::RangeFrom(range_from) => {
938                if word_count >= range_from.start {
939                    return true;
940                }
941            }
942            WordCountRange::RangeTo(rang_to) => {
943                if word_count < rang_to.end {
944                    return true;
945                }
946            }
947        }
948
949        false
950    }
951}