1use 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#[derive(Debug)]
284pub struct Client {
285 bench: Bench,
286 rx: mpsc::Receiver<StateData>,
287}
288
289#[derive(Clone, Debug)]
291pub struct User(Bench, i64);
292
293#[derive(Clone, Debug)]
295pub struct Xlive(Bench, i64, i64);
296
297impl Client {
298 #[must_use]
300 pub fn new() -> Self {
301 let (bench, rx) = Bench::new();
302 Self { bench, rx }
303 }
304
305 #[must_use]
307 pub fn user(&mut self, mid: i64) -> User {
308 self.do_sync();
309 User(self.bench.clone(), mid)
310 }
311
312 #[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 #[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 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 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 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 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 pub fn room_id(&self, id: i64) {
456 self.0.set_room_id(self.1, &id);
457 }
458
459 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 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}