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["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#[derive(Debug)]
322pub struct Client {
323 bench: Bench,
324 rx: mpsc::Receiver<StateData>,
325}
326
327#[derive(Clone, Debug)]
329pub struct User(Bench, i64);
330
331#[derive(Clone, Debug)]
333pub struct Xlive(Bench, i64, i64);
334
335impl Client {
336 #[must_use]
338 pub fn new() -> Self {
339 let (bench, rx) = Bench::new();
340 Self { bench, rx }
341 }
342
343 #[must_use]
345 pub fn user(&mut self, mid: i64) -> User {
346 self.do_sync();
347 User(self.bench.clone(), mid)
348 }
349
350 #[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 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 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 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 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 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 pub fn room_id(&self, id: i64) {
493 self.0.set_room_id(self.1, &id);
494 }
495
496 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 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}