Skip to main content

bpi_rs/search/
client.rs

1use crate::search::hot::{DefaultSearchData, HotWordDataResponse};
2use crate::search::result::{
3    Article, Bangumi, BiliUser, LiveData, LiveRoom, LiveUser, Movie, SearchData, Video,
4};
5use crate::search::search_params::{
6    SearchArticleParams, SearchBangumiParams, SearchBiliUserParams, SearchLiveParams,
7    SearchLiveRoomParams, SearchLiveUserParams, SearchMovieParams, SearchVideoParams,
8};
9use crate::search::suggest::{SearchSuggest, SearchSuggestParams};
10use crate::{BilibiliRequest, BpiClient, BpiResult};
11
12const TYPED_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/wbi/search/type";
13const DEFAULT_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/wbi/search/default";
14const SUGGEST_ENDPOINT: &str = "https://s.search.bilibili.com/main/suggest";
15const HOTWORDS_ENDPOINT: &str = "https://s.search.bilibili.com/main/hotword";
16
17/// Search API client.
18#[derive(Clone, Copy)]
19pub struct SearchClient<'a> {
20    pub(crate) client: &'a BpiClient,
21}
22
23impl<'a> SearchClient<'a> {
24    pub(crate) fn new(client: &'a BpiClient) -> Self {
25        Self { client }
26    }
27
28    #[cfg(test)]
29    pub(crate) fn typed_endpoint(&self) -> &'static str {
30        TYPED_ENDPOINT
31    }
32
33    #[cfg(test)]
34    pub(crate) fn default_endpoint(&self) -> &'static str {
35        DEFAULT_ENDPOINT
36    }
37
38    #[cfg(test)]
39    pub(crate) fn suggest_endpoint(&self) -> &'static str {
40        SUGGEST_ENDPOINT
41    }
42
43    #[cfg(test)]
44    pub(crate) fn hotwords_endpoint(&self) -> &'static str {
45        HOTWORDS_ENDPOINT
46    }
47
48    /// Searches article results.
49    pub async fn article(
50        &self,
51        params: SearchArticleParams,
52    ) -> BpiResult<SearchData<Vec<Article>>> {
53        self.typed_search(params.query_pairs(), "search.article")
54            .await
55    }
56
57    /// Searches bangumi results.
58    pub async fn bangumi(
59        &self,
60        params: SearchBangumiParams,
61    ) -> BpiResult<SearchData<Vec<Bangumi>>> {
62        self.typed_search(params.query_pairs(), "search.bangumi")
63            .await
64    }
65
66    /// Searches Bilibili user results.
67    pub async fn bili_user(
68        &self,
69        params: SearchBiliUserParams,
70    ) -> BpiResult<SearchData<Vec<BiliUser>>> {
71        self.typed_search(params.query_pairs(), "search.bili_user")
72            .await
73    }
74
75    /// Searches combined live room and live user results.
76    pub async fn live(&self, params: SearchLiveParams) -> BpiResult<SearchData<LiveData>> {
77        self.typed_search(params.query_pairs(), "search.live").await
78    }
79
80    /// Searches live room results.
81    pub async fn live_room(
82        &self,
83        params: SearchLiveRoomParams,
84    ) -> BpiResult<SearchData<Vec<LiveRoom>>> {
85        self.typed_search(params.query_pairs(), "search.live_room")
86            .await
87    }
88
89    /// Searches live user results.
90    pub async fn live_user(
91        &self,
92        params: SearchLiveUserParams,
93    ) -> BpiResult<SearchData<Vec<LiveUser>>> {
94        self.typed_search(params.query_pairs(), "search.live_user")
95            .await
96    }
97
98    /// Searches movie and film results.
99    pub async fn movie(&self, params: SearchMovieParams) -> BpiResult<SearchData<Vec<Movie>>> {
100        self.typed_search(params.query_pairs(), "search.movie")
101            .await
102    }
103
104    /// Searches video results.
105    pub async fn video(&self, params: SearchVideoParams) -> BpiResult<SearchData<Vec<Video>>> {
106        self.typed_search(params.query_pairs(), "search.video")
107            .await
108    }
109
110    /// Gets the default web search content.
111    pub async fn default(&self) -> BpiResult<DefaultSearchData> {
112        let signed_params = self.client.get_wbi_sign2(vec![("foo", "bar")]).await?;
113
114        self.client
115            .get(DEFAULT_ENDPOINT)
116            .query(&signed_params)
117            .send_bpi_payload("search.default")
118            .await
119    }
120
121    /// Gets search suggestions for a term.
122    pub async fn suggest(&self, params: SearchSuggestParams) -> BpiResult<SearchSuggest> {
123        self.client
124            .get(SUGGEST_ENDPOINT)
125            .query(&params.query_pairs())
126            .send_bpi_payload("search.suggest")
127            .await
128    }
129
130    /// Gets the web hotword list.
131    pub async fn hotwords(&self) -> BpiResult<HotWordDataResponse> {
132        let response = self.client.get(HOTWORDS_ENDPOINT).send().await?;
133
134        Ok(response.json().await?)
135    }
136
137    async fn typed_search<T>(
138        &self,
139        query_pairs: Vec<(&'static str, String)>,
140        endpoint_label: &'static str,
141    ) -> BpiResult<SearchData<T>>
142    where
143        T: serde::de::DeserializeOwned,
144    {
145        let signed_params = self.client.get_wbi_sign2(query_pairs).await?;
146
147        self.client
148            .get(TYPED_ENDPOINT)
149            .query(&signed_params)
150            .send_bpi_payload(endpoint_label)
151            .await
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::future::Future;
158
159    use crate::probe::contract::HttpMethod;
160    use crate::probe::endpoint_contract::EndpointContract;
161    use crate::search::hot::{DefaultSearchData, HotWordDataResponse};
162    use crate::search::result::{
163        Article, Bangumi, BiliUser, LiveData, LiveRoom, LiveUser, Movie, SearchData, Video,
164    };
165    use crate::search::search_params::{
166        SearchArticleParams, SearchBangumiParams, SearchBiliUserParams, SearchLiveParams,
167        SearchLiveRoomParams, SearchLiveUserParams, SearchMovieParams, SearchVideoParams,
168    };
169    use crate::search::suggest::{SearchSuggest, SearchSuggestParams};
170    use crate::{BpiClient, BpiResult};
171
172    fn assert_article_future<F>(_future: F)
173    where
174        F: Future<Output = BpiResult<SearchData<Vec<Article>>>>,
175    {
176    }
177
178    fn assert_bangumi_future<F>(_future: F)
179    where
180        F: Future<Output = BpiResult<SearchData<Vec<Bangumi>>>>,
181    {
182    }
183
184    fn assert_bili_user_future<F>(_future: F)
185    where
186        F: Future<Output = BpiResult<SearchData<Vec<BiliUser>>>>,
187    {
188    }
189
190    fn assert_live_future<F>(_future: F)
191    where
192        F: Future<Output = BpiResult<SearchData<LiveData>>>,
193    {
194    }
195
196    fn assert_live_room_future<F>(_future: F)
197    where
198        F: Future<Output = BpiResult<SearchData<Vec<LiveRoom>>>>,
199    {
200    }
201
202    fn assert_live_user_future<F>(_future: F)
203    where
204        F: Future<Output = BpiResult<SearchData<Vec<LiveUser>>>>,
205    {
206    }
207
208    fn assert_movie_future<F>(_future: F)
209    where
210        F: Future<Output = BpiResult<SearchData<Vec<Movie>>>>,
211    {
212    }
213
214    fn assert_video_future<F>(_future: F)
215    where
216        F: Future<Output = BpiResult<SearchData<Vec<Video>>>>,
217    {
218    }
219
220    fn assert_default_future<F>(_future: F)
221    where
222        F: Future<Output = BpiResult<DefaultSearchData>>,
223    {
224    }
225
226    fn assert_suggest_future<F>(_future: F)
227    where
228        F: Future<Output = BpiResult<SearchSuggest>>,
229    {
230    }
231
232    fn assert_hotwords_future<F>(_future: F)
233    where
234        F: Future<Output = BpiResult<HotWordDataResponse>>,
235    {
236    }
237
238    fn contract(endpoint: &str) -> BpiResult<EndpointContract> {
239        let bytes = match endpoint {
240            "article" => {
241                include_bytes!("../../tests/contracts/search/read/article/contract.json").as_slice()
242            }
243            "bangumi" => {
244                include_bytes!("../../tests/contracts/search/read/bangumi/contract.json").as_slice()
245            }
246            "bili-user" => {
247                include_bytes!("../../tests/contracts/search/read/bili-user/contract.json")
248                    .as_slice()
249            }
250            "live" => {
251                include_bytes!("../../tests/contracts/search/read/live/contract.json").as_slice()
252            }
253            "live-room" => {
254                include_bytes!("../../tests/contracts/search/read/live-room/contract.json")
255                    .as_slice()
256            }
257            "live-user" => {
258                include_bytes!("../../tests/contracts/search/read/live-user/contract.json")
259                    .as_slice()
260            }
261            "movie" => {
262                include_bytes!("../../tests/contracts/search/read/movie/contract.json").as_slice()
263            }
264            "video" => {
265                include_bytes!("../../tests/contracts/search/read/video/contract.json").as_slice()
266            }
267            "default" => {
268                include_bytes!("../../tests/contracts/search/read/default/contract.json").as_slice()
269            }
270            "suggest" => {
271                include_bytes!("../../tests/contracts/search/read/suggest/contract.json").as_slice()
272            }
273            "hotwords" => {
274                include_bytes!("../../tests/contracts/search/read/hotwords/contract.json")
275                    .as_slice()
276            }
277            _ => unreachable!("unknown search contract"),
278        };
279        EndpointContract::from_slice(bytes)
280    }
281
282    #[test]
283    fn search_client_exposes_promoted_endpoint_urls() -> BpiResult<()> {
284        let client = BpiClient::new()?;
285        let search = client.search();
286
287        assert_eq!(
288            search.typed_endpoint(),
289            "https://api.bilibili.com/x/web-interface/wbi/search/type"
290        );
291        assert_eq!(
292            search.default_endpoint(),
293            "https://api.bilibili.com/x/web-interface/wbi/search/default"
294        );
295        assert_eq!(
296            search.suggest_endpoint(),
297            "https://s.search.bilibili.com/main/suggest"
298        );
299        assert_eq!(
300            search.hotwords_endpoint(),
301            "https://s.search.bilibili.com/main/hotword"
302        );
303        Ok(())
304    }
305
306    #[test]
307    fn search_methods_return_payload_futures() -> BpiResult<()> {
308        let client = BpiClient::new()?;
309        let search = client.search();
310
311        assert_article_future(search.article(SearchArticleParams::new("rust")?));
312        assert_bangumi_future(search.bangumi(SearchBangumiParams::new("天气之子")?));
313        assert_bili_user_future(search.bili_user(SearchBiliUserParams::new("老番茄")?));
314        assert_live_future(search.live(SearchLiveParams::new("游戏")?));
315        assert_live_room_future(search.live_room(SearchLiveRoomParams::new("游戏")?));
316        assert_live_user_future(search.live_user(SearchLiveUserParams::new("散人")?));
317        assert_movie_future(search.movie(SearchMovieParams::new("哈利波特")?));
318        assert_video_future(search.video(SearchVideoParams::new("rust")?));
319        assert_default_future(search.default());
320        assert_suggest_future(search.suggest(SearchSuggestParams::new("rust")?));
321        assert_hotwords_future(search.hotwords());
322        Ok(())
323    }
324
325    #[test]
326    fn search_contracts_match_module_client_endpoints() -> BpiResult<()> {
327        let client = BpiClient::new()?;
328        let search = client.search();
329
330        let typed_expectations = [
331            ("article", "search.article"),
332            ("bangumi", "search.bangumi"),
333            ("bili-user", "search.bili_user"),
334            ("live", "search.live"),
335            ("live-room", "search.live_room"),
336            ("live-user", "search.live_user"),
337            ("movie", "search.movie"),
338            ("video", "search.video"),
339        ];
340
341        for (endpoint, name) in typed_expectations {
342            let contract = contract(endpoint)?;
343
344            assert_eq!(contract.name, name);
345            assert_eq!(contract.request.method, HttpMethod::Get);
346            assert_eq!(contract.request.url.as_str(), search.typed_endpoint());
347            assert!(contract.request.auth.requires_wbi());
348        }
349
350        let default = contract("default")?;
351        assert_eq!(default.name, "search.default");
352        assert_eq!(default.request.method, HttpMethod::Get);
353        assert_eq!(default.request.url.as_str(), search.default_endpoint());
354        assert!(default.request.auth.requires_wbi());
355
356        let suggest = contract("suggest")?;
357        assert_eq!(suggest.name, "search.suggest");
358        assert_eq!(suggest.request.method, HttpMethod::Get);
359        assert_eq!(suggest.request.url.as_str(), search.suggest_endpoint());
360        assert!(!suggest.request.auth.requires_wbi());
361
362        let hotwords = contract("hotwords")?;
363        assert_eq!(hotwords.name, "search.hotwords");
364        assert_eq!(hotwords.request.method, HttpMethod::Get);
365        assert_eq!(hotwords.request.url.as_str(), search.hotwords_endpoint());
366        assert!(!hotwords.request.auth.requires_wbi());
367        Ok(())
368    }
369}