bilibili_api_rs/
wbi.rs

1//! WBI means "web bibilili interface".
2use super::{cred_utils, Bench, Lodash, StateData};
3use anyhow::{bail, Context, Result};
4use log::{debug, trace};
5use regex::Regex;
6use reqwest::header::{CONTENT_TYPE, COOKIE, REFERER, USER_AGENT};
7use serde_json::json;
8use std::collections::btree_map::BTreeMap;
9use std::collections::HashSet;
10use tokio::sync::mpsc;
11
12type Json = serde_json::Value;
13
14async fn do_api_req(bench: &Bench, api_path: Json, opts: Json) -> Result<Json> {
15    api_result_validate(do_req_twice(bench, api_path, opts).await?)
16}
17
18fn api_result_validate(mut resp: Json) -> Result<Json> {
19    if matches!(resp, Json::String(_)) {
20        trace!("plaintext response bypass api_result_validate");
21        return Ok(resp);
22    }
23    if matches!(resp["code"].as_i64(), Some(0)) {
24        Ok(resp["data"].take())
25    } else {
26        bail!(
27            "bilibili api reject: {} {}",
28            resp["code"].as_i64().unwrap_or(-1),
29            resp["message"].as_str().unwrap_or("unknown")
30        );
31    }
32}
33
34async fn do_req_twice(bench: &Bench, api_path: Json, opts: Json) -> Result<Json> {
35    let state = &bench.state;
36    let mut mut_stat: Option<StateData> = None;
37
38    let k_domain = "Domain";
39    if state.get(k_domain).is_none() {
40        mut_stat = mut_stat
41            .or_else(|| Some(state.clone()))
42            .map(|s| s.update(k_domain.into(), ".bilibili.com".into()));
43    }
44
45    let k_salt = "wbi_salt";
46    if state.get(k_salt).is_none() {
47        debug!("do_req init salt");
48        let salt = fetch_wbi_salt(bench).await?;
49        mut_stat = mut_stat
50            .or_else(|| Some(state.clone()))
51            .map(|s| s.update(k_salt.into(), salt));
52    }
53
54    let k_uvid = "buvid3";
55    if state.get(k_uvid).is_none() {
56        debug!("do_req init uvid");
57        let uvid = fetch_uvid(bench).await?;
58        mut_stat = mut_stat
59            .or_else(|| Some(state.clone()))
60            .map(|s| s.update(k_uvid.into(), uvid));
61    }
62
63    if let Some(new_stat) = mut_stat {
64        bench.update_state(new_stat);
65        bail!("Require retry for update state");
66    }
67
68    do_req(bench, api_path, opts).await
69}
70
71fn gen_cookie(bench: &Bench) -> String {
72    let data = &bench.data;
73    let state = &bench.state;
74    data["cookie_state"]
75        .as_array()
76        .expect("data[cookie_state] should be array")
77        .iter()
78        .map(|x| x.as_str().expect("item of 'cookie_state' should be string"))
79        .map(|k| (k, state.get(k)))
80        .filter_map(|p| p.1.map(|v| (p.0, v)))
81        .map(|p| format!("{}={}", p.0, p.1))
82        .fold(String::new(), |acc, s| {
83            if acc.is_empty() {
84                s
85            } else {
86                format!("{acc}; {s}")
87            }
88        })
89}
90
91trait AttachHeaders {
92    fn headers_of_bench(self, bench: &Bench) -> Self;
93}
94
95impl AttachHeaders for reqwest::RequestBuilder {
96    fn headers_of_bench(self, bench: &Bench) -> Self {
97        let data = &bench.data;
98        self.header(
99            COOKIE,
100            gen_cookie(bench)
101                .parse::<reqwest::header::HeaderValue>()
102                .unwrap(),
103        )
104        .header(
105            REFERER,
106            data["headers"]["REFERER"]
107                .as_str()
108                .unwrap()
109                .parse::<reqwest::header::HeaderValue>()
110                .unwrap(),
111        )
112        .header(
113            USER_AGENT,
114            data["headers"]["USER_AGENT"]
115                .as_str()
116                .unwrap()
117                .parse::<reqwest::header::HeaderValue>()
118                .unwrap(),
119        )
120    }
121}
122
123async fn do_req(bench: &Bench, api_path: Json, mut opts: Json) -> Result<Json> {
124    debug!("do_req api_path: {:?}", &api_path);
125    let data = &bench.data;
126    let cli = reqwest::Client::new();
127    let api = data["api"].at(api_path);
128    if api["wbi"].as_bool().unwrap_or(false) {
129        let ts = chrono::Local::now().timestamp();
130        opts = enc_wbi(bench, opts, ts);
131    }
132    let req = cli
133        .request(
134            api["method"].as_str().unwrap_or("GET").parse().unwrap(),
135            api["url"].as_str().unwrap(),
136        )
137        .headers_of_bench(bench)
138        .query(&opts["query"]);
139    trace!("do_req: {:?}", &req);
140    if api["raw_content"].as_bool().unwrap_or(false) {
141        return Ok(Json::String(req.send().await?.text().await?));
142    }
143    Ok(
144        serde_json::from_str(&req.send().await?.text().await?).inspect(|resp| {
145            trace!("do_req resp: {:?}", &resp);
146        })?,
147    )
148}
149
150fn enc_wbi(bench: &Bench, mut opts: Json, ts: i64) -> Json {
151    let mut qs: BTreeMap<&str, String> = BTreeMap::new();
152    qs.insert("wts", ts.to_string());
153    for (k, v) in opts["query"].as_object().expect("query not json object") {
154        qs.insert(
155            k,
156            if v.is_string() {
157                v.as_str().unwrap().to_owned()
158            } else {
159                serde_json::to_string(v).expect("query value to_string error")
160            },
161        );
162    }
163    let uq: String = qs
164        .iter()
165        .map(|t| format!("{}={}", t.0, t.1))
166        .fold(String::new(), |acc, q| {
167            if acc.is_empty() {
168                q
169            } else {
170                acc + "&" + &q
171            }
172        });
173    opts["_uq"] = uq.clone().into();
174    opts["query"]["wts"] = ts.into();
175    let state = &bench.state;
176    opts["query"]["w_rid"] = Json::String(format!(
177        "{:x}",
178        md5::compute(
179            uq + state
180                .get("wbi_salt")
181                .expect("salt should be prepared before enc_wbi")
182        )
183    ));
184    opts
185}
186
187async fn fetch_uvid(bench: &Bench) -> Result<String> {
188    let mut spi = do_req(bench, json!(["credential", "info", "spi"]), json!({})).await?;
189    let Json::String(uvid) = spi["data"]["b_3"].take() else {
190        bail!("fetch_uvid: b_3 invalid");
191    };
192    let Json::String(uvid4) = spi["data"]["b_4"].take() else {
193        bail!("fetch_uvid: b_4 invalid");
194    };
195    active_buvid(bench, &uvid, &uvid4).await?;
196    Ok(uvid)
197}
198
199async fn active_buvid(bench: &Bench, uvid: &str, uvid4: &str) -> Result<()> {
200    let active_id = format!(
201        "{}{:05}infoc",
202        uuid::Uuid::new_v4().hyphenated(),
203        chrono::Local::now().timestamp_subsec_nanos() % 100_000
204    );
205    let payload = cred_utils::gen_payload(&active_id);
206    let cli = reqwest::Client::new();
207    let api = &bench.data["api"]["credential"]["operate"]["active"];
208    let mut buvid_bench = bench.clone();
209    {
210        let cookie = &mut buvid_bench.state;
211        cookie.insert("buvid3".into(), uvid.into());
212        cookie.insert("buvid4".into(), uvid4.into());
213        cookie.insert("buvid_fp".into(), cred_utils::gen_buvid_fp(&payload)?);
214        cookie.insert("_uuid".into(), active_id);
215    }
216    let req = cli
217        .request(
218            api["method"].as_str().unwrap_or("GET").parse().unwrap(),
219            api["url"].as_str().unwrap(),
220        )
221        .headers_of_bench(&buvid_bench)
222        .header(
223            CONTENT_TYPE,
224            "application/json"
225                .parse::<reqwest::header::HeaderValue>()
226                .unwrap(),
227        )
228        .json(&payload);
229    trace!("active buvid {:?}", &req);
230    let resp: serde_json::Value = serde_json::from_str(&req.send().await?.text().await?)?;
231    trace!("active buvid resp: {:?}", &resp);
232    let code = &resp["code"];
233    if !matches!(code.as_i64(), Some(0)) {
234        bail!(
235            "active buvid failed, code {:?}, msg {:?}",
236            code,
237            resp["msg"]
238        );
239    }
240    Ok(())
241}
242
243async fn fetch_wbi_salt(bench: &Bench) -> Result<String> {
244    let nav = do_req(bench, json!(["credential", "info", "valid"]), json!({})).await?;
245    let Some(imgurl) = nav["data"]["wbi_img"]["img_url"].as_str() else {
246        bail!("fetch_wbi_salt: wbi_img/img_url invalid");
247    };
248    let Some(suburl) = nav["data"]["wbi_img"]["sub_url"].as_str() else {
249        bail!("fetch_wbi_salt: wbi_img/sub_url invalid");
250    };
251    Ok(wbi_salt_compute(bench, imgurl, suburl))
252}
253
254fn wbi_parse_ae(imgurl: &str, suburl: &str) -> Option<String> {
255    let Ok(re) = Regex::new(r"https://i0\.hdslb\.com/bfs/wbi/(\w+)\.png") else {
256        return None;
257    };
258    let img = re.captures(imgurl)?.get(1)?.as_str();
259    let sub = re.captures(suburl)?.get(1)?.as_str();
260    Some(img.to_owned() + sub)
261}
262
263fn wbi_salt_compute(bench: &Bench, imgurl: &str, suburl: &str) -> String {
264    let ae: String = wbi_parse_ae(imgurl, suburl).unwrap_or_else(|| {
265        imgurl[imgurl.len() - 36..imgurl.len() - 4].to_owned()
266            + &suburl[suburl.len() - 36..suburl.len() - 4]
267    });
268    let oe: Vec<i64> = bench.data["wbi_oe"]
269        .as_array()
270        .expect("wbi_oe not array")
271        .iter()
272        .map(|v| v.as_i64().expect("wbi_oe[i] not i64"))
273        .collect();
274    let le: String = oe
275        .iter()
276        .filter_map(|x| usize::try_from(*x).ok())
277        .filter(|x| *x < ae.len())
278        .fold(String::new(), |acc, x| acc + &ae[x..=x]);
279    le[..32].into()
280}
281
282/// The root client base, see also [TOP][crate].
283#[derive(Debug)]
284pub struct Client {
285    bench: Bench,
286    rx: mpsc::Receiver<StateData>,
287}
288
289/// Remember user id and do GETs.
290#[derive(Clone, Debug)]
291pub struct User(Bench, i64);
292
293/// Remember parent area id and sub area id then do GETs.
294#[derive(Clone, Debug)]
295pub struct Xlive(Bench, i64, i64);
296
297impl Client {
298    /// Create a default instance.
299    #[must_use]
300    pub fn new() -> Self {
301        let (bench, rx) = Bench::new();
302        Self { bench, rx }
303    }
304
305    /// `mid` is *uid*
306    #[must_use]
307    pub fn user(&mut self, mid: i64) -> User {
308        self.do_sync();
309        User(self.bench.clone(), mid)
310    }
311
312    /// Renaming for logical. `area` is *`parent_area_id`*, `sub` is *`area_id`*.
313    #[must_use]
314    pub fn xlive(&mut self, area: i64, sub: i64) -> Xlive {
315        self.do_sync();
316        Xlive(self.bench.clone(), area, sub)
317    }
318
319    fn do_sync(&mut self) {
320        match self.rx.try_recv() {
321            Ok(s) => {
322                trace!("current state: {:?}", &s);
323                self.bench.state = s;
324            }
325            Err(mpsc::error::TryRecvError::Disconnected) => {
326                panic!("existing client should have health channel")
327            }
328            _ => (),
329        }
330    }
331}
332
333impl Default for Client {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339impl User {
340    /// See also [*api_info/user:info/info*][api_info/user]
341    ///
342    /// [api_info/user]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/user.json
343    ///
344    /// # Errors
345    /// Throw network errors or api errors.
346    #[deprecated = "WBI is stateful protected. Replace it with card/live_info"]
347    pub async fn info(&self) -> Result<Json> {
348        do_api_req(
349            &self.0,
350            json!(["user", "info", "info"]),
351            json!({"query":{
352                "mid":self.1,
353                "web_location": 1_550_101,
354            }}),
355        )
356        .await
357    }
358
359    /// See also [*api_info/unstable:videos*][api_info/unstable]
360    ///
361    /// [api_info/unstable]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/unstable.json
362    ///
363    /// # Errors
364    /// Throw network errors or api errors.
365    pub async fn latest_videos(&self) -> Result<Json> {
366        do_api_req(
367            &self.0,
368            json!(["unstable", "videos"]),
369            json!({
370                "query": {
371                    "mobi_app": "web",
372                    "type": 1,
373                    "biz_id": self.1,
374                    "oid": "",
375                    "otype": 2,
376                    "ps": 2,
377                    "direction": false,
378                    "desc": true,
379                    "sort_field": 1,
380                    "tid": 0,
381                    "with_current": false
382                }
383            }),
384        )
385        .await
386    }
387
388    /// See also [*api_info/user:info/dynamic*][api_info/user]
389    ///
390    /// [api_info/user]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/user.json
391    ///
392    /// # Errors
393    /// Throw network errors or api errors.
394    pub async fn recent_posts(&self) -> Result<Json> {
395        do_api_req(
396            &self.0,
397            json!(["user", "info", "dynamic"]),
398            json!({
399                "query": {
400                    "host_uid": self.1,
401                    "offset_dynamic_id": 0,
402                    "need_top": 0,
403                }
404            }),
405        )
406        .await
407    }
408
409    /// See also [*api_info/unstable:card*][api_info/unstable]
410    ///
411    /// [api_info/unstable]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/unstable.json
412    ///
413    /// # Errors
414    /// Throw network errors or api errors.
415    pub async fn card(&self) -> Result<Json> {
416        do_api_req(
417            &self.0,
418            json!(["unstable", "card"]),
419            json!({
420                "query": {
421                    "mid": self.1,
422                    "photo": 1,
423                }
424            }),
425        )
426        .await
427    }
428
429    /// Invoke `search_room` if room id not found, otherwise query room info.
430    /// See also [*api_info/live:info/room_info*][api_info/live]
431    ///
432    /// [api_info/live]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/live.json
433    ///
434    /// # Errors
435    /// Throw network errors or api errors.
436    pub async fn live_info(&self) -> Result<Json> {
437        let Some(room_id) = self.0.get_room_id(self.1) else {
438            let room_id = self.search_room().await?;
439            self.0.set_room_id(self.1, &room_id);
440            bail!("init room id of uid {}: {}", self.1, room_id);
441        };
442        do_api_req(
443            &self.0,
444            json!(["live", "info", "room_info"]),
445            json!({
446                "query": {
447                    "room_id": room_id,
448                }
449            }),
450        )
451        .await
452    }
453
454    /// External init room id for user.
455    pub fn room_id(&self, id: i64) {
456        self.0.set_room_id(self.1, &id);
457    }
458
459    /// Search room of user and filter check with room play info.
460    /// See also [*api_info/unstable:room_search*][api_info/unstable]
461    ///
462    /// [api_info/unstable]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/unstable.json
463    ///
464    /// # Errors
465    /// Mostly if live stream stopped. Otherwise network errors or api errors.
466    ///
467    /// # Panics
468    /// Internal failures.
469    pub async fn search_room(&self) -> Result<String> {
470        let card = self.card().await?;
471        let html = do_api_req(
472            &self.0,
473            json!(["unstable", "room_search"]),
474            json!({
475                "query": {
476                    "keyword": card["card"]["name"],
477                    "from_source": "webtop_search",
478                    "spm_id_from": "333.999",
479                    "search_source": 5
480                }
481            }),
482        )
483        .await
484        .context("api room_search")?;
485        trace!("search room html: {html:?}");
486        let html_txt = html
487            .as_str()
488            .expect("api_info/unstable:room_search result must be plaintext");
489        let re = Regex::new(r#"href="//live\.bilibili\.com/(\d+)\?live_from"#)
490            .expect("search room html regex must ok");
491        let mut rid_set: HashSet<String> = HashSet::new();
492        for (_, [room_id]) in re.captures_iter(html_txt).map(|c| c.extract()) {
493            rid_set.insert(room_id.to_owned());
494        }
495        for room_id in rid_set {
496            if let Ok(check) = do_api_req(
497                &self.0,
498                json!(["live", "info", "room_play_info"]),
499                json!({
500                    "query": {
501                        "room_id": room_id,
502                    }
503                }),
504            )
505            .await
506            {
507                if matches!(check["uid"].as_i64(), Some(id) if id == self.1) {
508                    return Ok(room_id.clone());
509                }
510            };
511        }
512        bail!("live room not found, mostly live closed, uid:{}", self.1)
513    }
514}
515
516impl Xlive {
517    /// Check [*api_info/xlive:info/get_list*][api_info/xlive]
518    ///
519    /// [api_info/xlive]: https://github.com/lifeich1/bilibili-api-rs/blob/master/src/api_info/xlive.json
520    ///
521    /// # Errors
522    /// Throw network errors or api errors.
523    pub async fn list(&self, pn: i64) -> Result<Json> {
524        do_api_req(
525            &self.0,
526            json!(["xlive", "info", "get_list"]),
527            json!({
528                "query": {
529                    "parent_area_id": self.1,
530                    "area_id": self.2,
531                    "page": pn,
532                    "sort_type": "sort_type_291",
533                    "platform": "web",
534                }
535            }),
536        )
537        .await
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use serde_json::json;
545
546    fn init() {
547        env_logger::builder()
548            .is_test(true)
549            .format_timestamp(Some(env_logger::fmt::TimestampPrecision::Micros))
550            .try_init()
551            .ok();
552    }
553
554    #[tokio::test]
555    async fn test_cover_all_api() {
556        init();
557        let banned = 328_575_117;
558        let cctv = 222_103_174;
559        let mut cli = Client::new();
560        let banned_info = cli.user(banned).card().await;
561        assert!(banned_info.is_err());
562        assert!(banned_info
563            .unwrap_err()
564            .to_string()
565            .contains("Require retry for update state"));
566        let banned_info = cli.user(banned).card().await;
567        assert!(banned_info.is_ok());
568        println!("banned_info: {banned_info:?}");
569        assert!(matches!(
570            banned_info.unwrap()["card"]["spacesta"].as_i64(),
571            Some(-2)
572        ));
573        let info = cli.user(cctv).card().await;
574        assert!(info.is_ok());
575        assert!(cli.user(cctv).recent_posts().await.is_ok());
576        assert!(cli.user(cctv).latest_videos().await.is_ok());
577
578        let study24h = 1_685_650_605;
579        let info = cli.user(study24h).search_room().await;
580        println!("info: {:?}", &info);
581        assert!(info.is_ok());
582        assert_eq!(info.ok(), Some("27519423".to_owned()));
583
584        let info = cli.user(study24h).live_info().await;
585        assert!(info.is_err());
586        assert!(info
587            .unwrap_err()
588            .to_string()
589            .contains("init room id of uid"));
590
591        let info = cli.user(study24h).live_info().await;
592        assert!(info.is_ok());
593        let info = info.unwrap();
594        assert_eq!(info["room_info"]["live_status"], json!(1));
595        assert_eq!(info["room_info"]["room_id"], json!(27_519_423));
596
597        let area_drug = 1;
598        let type_moe = 530;
599        assert!(cli.xlive(area_drug, type_moe).list(1).await.is_ok());
600    }
601
602    #[test]
603    fn test_wbi_salt_compute() {
604        let bench = Bench::new().0;
605        let le = wbi_salt_compute(
606            &bench,
607            "https://i0.hdslb.com/bfs/wbi/e130e5f398924e569b7cca9f4713ec63.png",
608            "https://i0.hdslb.com/bfs/wbi/65c711c1f26b475a9305dad9f9903782.png",
609        );
610        assert_eq!(le, "5a73a9f6609390773b53586cce514c2e");
611    }
612
613    #[tokio::test]
614    async fn test_fetch_wbi_salt() -> Result<()> {
615        let bench = Bench::new().0;
616        let salt = fetch_wbi_salt(&bench).await?;
617        assert_eq!(salt.len(), 32);
618        Ok(())
619    }
620
621    #[test]
622    fn test_enc_wbi() {
623        let salt = "b7ot4is0ba.3cp9fi5:ce0eme/l9d84s";
624        let mut bench = Bench::new().0;
625        bench.state.insert("wbi_salt".into(), salt.to_owned());
626        let opts = enc_wbi(
627            &bench,
628            json!({
629                "query": {
630                    "mid": 213_741,
631                }
632            }),
633            1_686_163_791,
634        );
635        assert_eq!(
636            opts,
637            json!({
638                "_uq": "mid=213741&wts=1686163791",
639                "query": {
640                    "w_rid": "dc7bb638dc082c354fd9624b72374f3b",
641                    "mid": 213_741,
642                    "wts": 1_686_163_791,
643                },
644            })
645        );
646    }
647
648    #[test]
649    fn test_enc_wbi2() {
650        let salt = "ea1db124af3c7062474693fa704f4ff8";
651        let mut bench = Bench::new().0;
652        bench.state.insert("wbi_salt".into(), salt.to_owned());
653        let opts = enc_wbi(
654            &bench,
655            json!({
656                "query": {
657                    "mid": 222_103_174,
658                    "web_location": 1_550_101,
659                }
660            }),
661            1_714_929_805,
662        );
663        assert_eq!(
664            opts,
665            json!({
666                "_uq": "mid=222103174&web_location=1550101&wts=1714929805",
667                "query": {
668                    "wts": 1_714_929_805,
669                    "w_rid": "0ef355650a5979e017ccf135200b18f6",
670                    "mid": 222_103_174,
671                    "web_location": 1_550_101,
672                },
673            })
674        );
675    }
676}