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