novel_cli/cmd/
search.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use clap::Args;
5use color_eyre::eyre::{self, Result};
6use fluent_templates::Loader;
7use novel_api::{
8    CiweimaoClient, CiyuanjiClient, Client, Options, SfacgClient, Tag, WordCountRange,
9};
10use tokio::sync::Semaphore;
11use url::Url;
12
13use crate::cmd::{Convert, Source};
14use crate::{LANG_ID, LOCALES, utils};
15
16#[must_use]
17#[derive(Args)]
18#[command(arg_required_else_help = true,
19    about = LOCALES.lookup(&LANG_ID, "search_command"))]
20pub struct Search {
21    #[arg(short, long,
22        help = LOCALES.lookup(&LANG_ID, "source"))]
23    pub source: Source,
24
25    #[arg(long, default_value_t = false,
26        help = LOCALES.lookup(&LANG_ID, "show_categories"))]
27    pub show_categories: bool,
28
29    #[arg(long, default_value_t = false,
30        help = LOCALES.lookup(&LANG_ID, "show_tags"))]
31    pub show_tags: bool,
32
33    #[arg(help = LOCALES.lookup(&LANG_ID, "keyword"))]
34    pub keyword: Option<String>,
35
36    #[arg(long, help = LOCALES.lookup(&LANG_ID, "min_word_count"))]
37    pub min_word_count: Option<u32>,
38
39    #[arg(long, help = LOCALES.lookup(&LANG_ID, "max_word_count"))]
40    pub max_word_count: Option<u32>,
41
42    #[arg(long, help = LOCALES.lookup(&LANG_ID, "update_days"))]
43    pub update_days: Option<u8>,
44
45    #[arg(long, help = LOCALES.lookup(&LANG_ID, "is_finished"))]
46    pub is_finished: Option<bool>,
47
48    #[arg(long, help = LOCALES.lookup(&LANG_ID, "is_vip"))]
49    pub is_vip: Option<bool>,
50
51    #[arg(long, help = LOCALES.lookup(&LANG_ID, "category"))]
52    pub category: Option<String>,
53
54    #[arg(long, value_delimiter = ',',
55        help = LOCALES.lookup(&LANG_ID, "tags"))]
56    pub tags: Vec<String>,
57
58    #[arg(long, value_delimiter = ',',
59    help = LOCALES.lookup(&LANG_ID, "excluded_tags"))]
60    pub excluded_tags: Vec<String>,
61
62    #[arg(long, default_value_t = 10, value_parser = clap::value_parser!(u8).range(1..=100),
63      help = LOCALES.lookup(&LANG_ID, "limit"))]
64    pub limit: u8,
65
66    #[arg(short, long, value_enum, value_delimiter = ',',
67        help = LOCALES.lookup(&LANG_ID, "converts"))]
68    pub converts: Vec<Convert>,
69
70    #[arg(long, default_value_t = false,
71        help = LOCALES.lookup(&LANG_ID, "ignore_keyring"))]
72    pub ignore_keyring: bool,
73
74    #[arg(short, long, default_value_t = 8, value_parser = clap::value_parser!(u8).range(1..=8),
75    help = LOCALES.lookup(&LANG_ID, "maximum_concurrency"))]
76    pub maximum_concurrency: u8,
77
78    #[arg(long, num_args = 0..=1, default_missing_value = super::DEFAULT_PROXY,
79        help = LOCALES.lookup(&LANG_ID, "proxy"))]
80    pub proxy: Option<Url>,
81
82    #[arg(long, default_value_t = false,
83        help = LOCALES.lookup(&LANG_ID, "no_proxy"))]
84    pub no_proxy: bool,
85
86    #[arg(long, num_args = 0..=1, default_missing_value = super::default_cert_path(),
87        help = super::cert_help_msg())]
88    pub cert: Option<PathBuf>,
89}
90
91pub async fn execute(config: Search) -> Result<()> {
92    match config.source {
93        Source::Sfacg => {
94            let mut client = SfacgClient::new().await?;
95            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
96            do_execute(client, config).await?;
97        }
98        Source::Ciweimao => {
99            let mut client = CiweimaoClient::new().await?;
100            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
101            utils::log_in(&client, &config.source, config.ignore_keyring).await?;
102            do_execute(client, config).await?;
103        }
104        Source::Ciyuanji => {
105            let mut client = CiyuanjiClient::new().await?;
106            super::set_options(&mut client, &config.proxy, &config.no_proxy, &config.cert);
107            utils::log_in_without_password(&client).await?;
108            do_execute(client, config).await?;
109        }
110    }
111
112    Ok(())
113}
114
115async fn do_execute<T>(client: T, config: Search) -> Result<()>
116where
117    T: Client + Send + Sync + 'static,
118{
119    let client = Arc::new(client);
120    super::handle_shutdown_signal(&client);
121
122    if config.show_categories {
123        let categories = client.categories().await?;
124        println!("{}", vec_to_string(categories)?);
125    } else if config.show_tags {
126        let tags = client.tags().await?;
127        println!("{}", vec_to_string(tags)?);
128    } else {
129        let mut page = 0;
130        let semaphore = Arc::new(Semaphore::new(config.maximum_concurrency as usize));
131
132        let options = create_options(&client, &config).await?;
133        tracing::debug!("{options:#?}");
134
135        let mut novel_infos = Vec::new();
136        loop {
137            let size = u16::clamp(config.limit as u16 - novel_infos.len() as u16, 10, 50);
138
139            let novel_ids = client.search_infos(&options, page, size).await?;
140            page += 1;
141
142            if novel_ids.is_none() {
143                break;
144            }
145
146            let mut handles = Vec::new();
147            for novel_id in novel_ids.unwrap() {
148                let client = Arc::clone(&client);
149                let permit = semaphore.clone().acquire_owned().await.unwrap();
150
151                handles.push(tokio::spawn(async move {
152                    let novel_info = utils::novel_info(&client, novel_id).await?;
153                    drop(permit);
154                    eyre::Ok(novel_info)
155                }));
156            }
157
158            for handle in handles {
159                let novel_info = handle.await??;
160
161                if !novel_infos.contains(&novel_info) {
162                    novel_infos.push(novel_info);
163                }
164            }
165
166            if novel_infos.len() >= config.limit as usize {
167                break;
168            }
169        }
170
171        novel_infos.truncate(config.limit as usize);
172
173        utils::print_novel_infos(novel_infos, &config.converts)?;
174    }
175
176    Ok(())
177}
178
179async fn create_options<T>(client: &Arc<T>, config: &Search) -> Result<Options>
180where
181    T: Client,
182{
183    let mut options = Options {
184        keyword: config.keyword.clone(),
185        is_finished: config.is_finished,
186        is_vip: config.is_vip,
187        update_days: config.update_days,
188        ..Default::default()
189    };
190
191    if config.category.is_some() {
192        let categories = client.categories().await?;
193        let categories_name = config.category.as_ref().unwrap();
194
195        match categories
196            .iter()
197            .find(|category| category.name == *categories_name)
198        {
199            Some(category) => options.category = Some(category.clone()),
200            None => {
201                eyre::bail!(
202                    "The category was not found: `{categories_name}`, all available categories are: `{}`",
203                    vec_to_string(categories)?
204                );
205            }
206        }
207    }
208
209    if !config.tags.is_empty() {
210        options.tags = Some(to_tags(client, &config.tags).await?)
211    }
212
213    if !config.excluded_tags.is_empty() {
214        options.excluded_tags = Some(to_tags(client, &config.excluded_tags).await?)
215    }
216
217    if config.min_word_count.is_some() && config.max_word_count.is_none() {
218        options.word_count = Some(WordCountRange::RangeFrom(config.min_word_count.unwrap()..));
219    } else if config.min_word_count.is_none() && config.max_word_count.is_some() {
220        options.word_count = Some(WordCountRange::RangeTo(..config.max_word_count.unwrap()));
221    } else if config.min_word_count.is_some() && config.max_word_count.is_some() {
222        options.word_count = Some(WordCountRange::Range(
223            config.min_word_count.unwrap()..config.max_word_count.unwrap(),
224        ));
225    }
226
227    Ok(options)
228}
229
230async fn to_tags<T>(client: &Arc<T>, tag_names: &Vec<String>) -> Result<Vec<Tag>>
231where
232    T: Client,
233{
234    let mut result = Vec::new();
235
236    let tags = client.tags().await?;
237    for tag_name in tag_names {
238        match tags.iter().find(|tag| tag.name == *tag_name) {
239            Some(tag) => result.push(tag.clone()),
240            None => {
241                eyre::bail!(
242                    "The tag was not found: `{tag_name}`, all available tags are: `{}`",
243                    vec_to_string(tags)?
244                );
245            }
246        }
247    }
248
249    Ok(result)
250}
251
252fn vec_to_string<T>(vec: &[T]) -> Result<String>
253where
254    T: ToString,
255{
256    let result = vec
257        .iter()
258        .map(|item| item.to_string())
259        .collect::<Vec<String>>()
260        .join("、");
261
262    Ok(result)
263}