1use crate::{ApiEnvelope, BilibiliRequest, BpiClient, BpiError, BpiResult};
2
3use super::login_action::captcha::{GeetestData, GenerateCaptcha};
4use super::login_action::qr::{CheckQrCodeStatusData, GenerateQrCodeData};
5use super::login_notice::{LoginLogData, LoginNoticeData};
6use super::model::{
7 LoginAccountInfo, LoginCoinBalance, LoginDailyReward, LoginNav, LoginStats, LoginTodayCoinExp,
8 LoginVipInfo,
9};
10use super::params::{LoginLogParams, LoginNoticeParams, LoginQrPollParams};
11
12const NAV_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/nav";
13const STAT_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/nav/stat";
14const COIN_ENDPOINT: &str = "https://account.bilibili.com/site/getCoin";
15const TODAY_COIN_EXP_ENDPOINT: &str = "https://api.bilibili.com/x/web-interface/coin/today/exp";
16const DAILY_REWARD_ENDPOINT: &str = "https://api.bilibili.com/x/member/web/exp/reward";
17const ACCOUNT_INFO_ENDPOINT: &str = "https://api.bilibili.com/x/member/web/account";
18const VIP_INFO_ENDPOINT: &str = "https://api.bilibili.com/x/vip/web/user/info";
19const NOTICE_ENDPOINT: &str = "https://api.bilibili.com/x/safecenter/login_notice";
20const LOG_ENDPOINT: &str = "https://api.bilibili.com/x/member/web/login/log";
21const CAPTCHA_GENERATE_ENDPOINT: &str = "https://passport.bilibili.com/x/passport-login/captcha";
22const CAPTCHA_SOURCE: &str = "main_web";
23const QR_GENERATE_ENDPOINT: &str =
24 "https://passport.bilibili.com/x/passport-login/web/qrcode/generate";
25const QR_POLL_ENDPOINT: &str = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll";
26
27#[derive(Clone, Copy)]
29pub struct LoginClient<'a> {
30 pub(crate) client: &'a BpiClient,
31}
32
33impl<'a> LoginClient<'a> {
34 pub(crate) fn new(client: &'a BpiClient) -> Self {
35 Self { client }
36 }
37
38 #[cfg(test)]
39 pub(crate) fn nav_endpoint(&self) -> &'static str {
40 NAV_ENDPOINT
41 }
42
43 #[cfg(test)]
44 pub(crate) fn stat_endpoint(&self) -> &'static str {
45 STAT_ENDPOINT
46 }
47
48 #[cfg(test)]
49 pub(crate) fn coin_endpoint(&self) -> &'static str {
50 COIN_ENDPOINT
51 }
52
53 #[cfg(test)]
54 pub(crate) fn today_coin_exp_endpoint(&self) -> &'static str {
55 TODAY_COIN_EXP_ENDPOINT
56 }
57
58 #[cfg(test)]
59 pub(crate) fn daily_reward_endpoint(&self) -> &'static str {
60 DAILY_REWARD_ENDPOINT
61 }
62
63 #[cfg(test)]
64 pub(crate) fn account_info_endpoint(&self) -> &'static str {
65 ACCOUNT_INFO_ENDPOINT
66 }
67
68 #[cfg(test)]
69 pub(crate) fn vip_info_endpoint(&self) -> &'static str {
70 VIP_INFO_ENDPOINT
71 }
72
73 #[cfg(test)]
74 pub(crate) fn notice_endpoint(&self) -> &'static str {
75 NOTICE_ENDPOINT
76 }
77
78 #[cfg(test)]
79 pub(crate) fn log_endpoint(&self) -> &'static str {
80 LOG_ENDPOINT
81 }
82
83 #[cfg(test)]
84 pub(crate) fn captcha_generate_endpoint(&self) -> &'static str {
85 CAPTCHA_GENERATE_ENDPOINT
86 }
87
88 #[cfg(test)]
89 pub(crate) fn qr_generate_endpoint(&self) -> &'static str {
90 QR_GENERATE_ENDPOINT
91 }
92
93 #[cfg(test)]
94 pub(crate) fn qr_poll_endpoint(&self) -> &'static str {
95 QR_POLL_ENDPOINT
96 }
97
98 pub async fn nav(&self) -> BpiResult<LoginNav> {
100 self.client
101 .get(NAV_ENDPOINT)
102 .send_bpi_payload("login.nav")
103 .await
104 }
105
106 pub async fn stat(&self) -> BpiResult<LoginStats> {
108 self.client
109 .get(STAT_ENDPOINT)
110 .send_bpi_payload("login.stat")
111 .await
112 }
113
114 pub async fn coin(&self) -> BpiResult<LoginCoinBalance> {
116 self.client
117 .get(COIN_ENDPOINT)
118 .send_bpi_payload("login.coin")
119 .await
120 }
121
122 pub async fn today_coin_exp(&self) -> BpiResult<LoginTodayCoinExp> {
124 self.client
125 .get(TODAY_COIN_EXP_ENDPOINT)
126 .send_bpi_payload("login.today_coin_exp")
127 .await
128 }
129
130 pub async fn daily_reward(&self) -> BpiResult<LoginDailyReward> {
132 self.client
133 .get(DAILY_REWARD_ENDPOINT)
134 .send_bpi_payload("login.daily_reward")
135 .await
136 }
137
138 pub async fn account_info(&self) -> BpiResult<LoginAccountInfo> {
140 self.client
141 .get(ACCOUNT_INFO_ENDPOINT)
142 .send_bpi_payload("login.account_info")
143 .await
144 }
145
146 pub async fn vip_info(&self) -> BpiResult<LoginVipInfo> {
148 self.client
149 .get(VIP_INFO_ENDPOINT)
150 .send_bpi_payload("login.vip_info")
151 .await
152 }
153
154 pub async fn notice(&self, params: LoginNoticeParams) -> BpiResult<LoginNoticeData> {
156 self.client
157 .get(NOTICE_ENDPOINT)
158 .with_bilibili_headers()
159 .query(¶ms.query_pairs())
160 .send_bpi_payload("login.notice")
161 .await
162 }
163
164 pub async fn log(&self, params: LoginLogParams) -> BpiResult<LoginLogData> {
166 self.client
167 .get(LOG_ENDPOINT)
168 .with_bilibili_headers()
169 .query(¶ms.query_pairs())
170 .send_bpi_payload("login.log")
171 .await
172 }
173
174 pub async fn generate_captcha(&self) -> BpiResult<GenerateCaptcha> {
176 let data: GeetestData = self
177 .client
178 .get(CAPTCHA_GENERATE_ENDPOINT)
179 .with_bilibili_headers()
180 .query(&[("source", CAPTCHA_SOURCE)])
181 .send_bpi_payload("login.captcha_generate")
182 .await?;
183 let geetest = data.geetest;
184
185 Ok(GenerateCaptcha {
186 token: data.token,
187 gt: geetest.gt,
188 challenge: geetest.challenge,
189 })
190 }
191
192 pub async fn qr_generate(&self) -> BpiResult<GenerateQrCodeData> {
194 self.client
195 .get(QR_GENERATE_ENDPOINT)
196 .with_bilibili_headers()
197 .send_bpi_payload("login.qr_generate")
198 .await
199 }
200
201 pub async fn qr_poll(&self, params: LoginQrPollParams) -> BpiResult<CheckQrCodeStatusData> {
203 let response = self
204 .client
205 .get(QR_POLL_ENDPOINT)
206 .with_bilibili_headers()
207 .query(¶ms.query_pairs())
208 .send()
209 .await?;
210
211 let cookies: Vec<(String, String)> = response
212 .cookies()
213 .map(|cookie| (cookie.name().to_string(), cookie.value().to_string()))
214 .collect();
215 let envelope: ApiEnvelope<CheckQrCodeStatusData> = response
216 .json()
217 .await
218 .map_err(|err| BpiError::parse(err.to_string()))?;
219 let mut data = envelope.into_data()?;
220
221 if data.code == 0 {
222 data.cookies = cookies;
223 }
224
225 Ok(data)
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use std::future::Future;
232
233 use super::DAILY_REWARD_ENDPOINT;
234
235 use crate::BpiClient;
236 use crate::ids::Mid;
237 use crate::login::login_action::captcha::GenerateCaptcha;
238 use crate::login::login_action::qr::{CheckQrCodeStatusData, GenerateQrCodeData};
239 use crate::login::login_notice::{LoginLogData, LoginNoticeData};
240 use crate::login::{LoginLogParams, LoginNoticeParams, LoginQrPollParams};
241 use crate::probe::contract::HttpMethod;
242 use crate::probe::endpoint_contract::EndpointContract;
243 use crate::{BpiError, BpiResult};
244
245 const READ_INFO_CONTRACTS: &[(&str, &str, &str)] = &[
246 (
247 "account-info",
248 "login.account_info",
249 "https://api.bilibili.com/x/member/web/account",
250 ),
251 (
252 "coin",
253 "login.coin",
254 "https://account.bilibili.com/site/getCoin",
255 ),
256 (
257 "nav",
258 "login.nav",
259 "https://api.bilibili.com/x/web-interface/nav",
260 ),
261 (
262 "stat",
263 "login.stat",
264 "https://api.bilibili.com/x/web-interface/nav/stat",
265 ),
266 (
267 "today-coin-exp",
268 "login.today_coin_exp",
269 "https://api.bilibili.com/x/web-interface/coin/today/exp",
270 ),
271 ];
272
273 fn endpoint_contract(endpoint: &str) -> Result<EndpointContract, Box<dyn std::error::Error>> {
274 let path = format!("tests/contracts/login/{endpoint}/contract.json");
275 let bytes = std::fs::read(path)?;
276 Ok(EndpointContract::from_slice(&bytes)?)
277 }
278
279 fn read_info_contract(endpoint: &str) -> Result<EndpointContract, Box<dyn std::error::Error>> {
280 endpoint_contract(endpoint)
281 }
282
283 fn nested_contract(path: &str) -> Result<EndpointContract, BpiError> {
284 let bytes = match path {
285 "notice/login-notice" => {
286 include_bytes!("../../tests/contracts/login/notice/login-notice/contract.json")
287 .as_slice()
288 }
289 "notice/login-log" => {
290 include_bytes!("../../tests/contracts/login/notice/login-log/contract.json")
291 .as_slice()
292 }
293 "captcha/generate" => {
294 include_bytes!("../../tests/contracts/login/captcha/generate/contract.json")
295 .as_slice()
296 }
297 "qr/generate" => {
298 include_bytes!("../../tests/contracts/login/qr/generate/contract.json").as_slice()
299 }
300 "qr/poll" => {
301 include_bytes!("../../tests/contracts/login/qr/poll/contract.json").as_slice()
302 }
303 _ => unreachable!("unknown login nested contract"),
304 };
305
306 EndpointContract::from_slice(bytes)
307 }
308
309 fn assert_notice_future<F>(_future: F)
310 where
311 F: Future<Output = BpiResult<LoginNoticeData>>,
312 {
313 }
314
315 fn assert_log_future<F>(_future: F)
316 where
317 F: Future<Output = BpiResult<LoginLogData>>,
318 {
319 }
320
321 fn assert_captcha_future<F>(_future: F)
322 where
323 F: Future<Output = BpiResult<GenerateCaptcha>>,
324 {
325 }
326
327 fn assert_qr_generate_future<F>(_future: F)
328 where
329 F: Future<Output = BpiResult<GenerateQrCodeData>>,
330 {
331 }
332
333 fn assert_qr_poll_future<F>(_future: F)
334 where
335 F: Future<Output = BpiResult<CheckQrCodeStatusData>>,
336 {
337 }
338
339 #[test]
340 fn login_client_borrows_root_client() -> Result<(), crate::BpiError> {
341 let client = BpiClient::new()?;
342 let login = client.login();
343
344 assert_eq!(
345 login.nav_endpoint(),
346 "https://api.bilibili.com/x/web-interface/nav"
347 );
348 Ok(())
349 }
350
351 #[test]
352 fn login_client_exposes_stat_endpoint() -> Result<(), crate::BpiError> {
353 let client = BpiClient::new()?;
354 let login = client.login();
355
356 assert_eq!(
357 login.stat_endpoint(),
358 "https://api.bilibili.com/x/web-interface/nav/stat"
359 );
360 Ok(())
361 }
362
363 #[test]
364 fn login_client_exposes_coin_endpoint() -> Result<(), crate::BpiError> {
365 let client = BpiClient::new()?;
366 let login = client.login();
367
368 assert_eq!(
369 login.coin_endpoint(),
370 "https://account.bilibili.com/site/getCoin"
371 );
372 Ok(())
373 }
374
375 #[test]
376 fn login_client_exposes_today_coin_exp_endpoint() -> Result<(), crate::BpiError> {
377 let client = BpiClient::new()?;
378 let login = client.login();
379
380 assert_eq!(
381 login.today_coin_exp_endpoint(),
382 "https://api.bilibili.com/x/web-interface/coin/today/exp"
383 );
384 Ok(())
385 }
386
387 #[test]
388 fn login_client_exposes_daily_reward_endpoint() -> Result<(), crate::BpiError> {
389 let client = BpiClient::new()?;
390 let login = client.login();
391
392 assert_eq!(
393 login.daily_reward_endpoint(),
394 "https://api.bilibili.com/x/member/web/exp/reward"
395 );
396 Ok(())
397 }
398
399 #[test]
400 fn login_client_exposes_account_info_endpoint() -> Result<(), crate::BpiError> {
401 let client = BpiClient::new()?;
402 let login = client.login();
403
404 assert_eq!(
405 login.account_info_endpoint(),
406 "https://api.bilibili.com/x/member/web/account"
407 );
408 Ok(())
409 }
410
411 #[test]
412 fn login_client_exposes_vip_info_endpoint() -> Result<(), crate::BpiError> {
413 let client = BpiClient::new()?;
414 let login = client.login();
415
416 assert_eq!(
417 login.vip_info_endpoint(),
418 "https://api.bilibili.com/x/vip/web/user/info"
419 );
420 Ok(())
421 }
422
423 #[test]
424 fn login_safe_flow_client_methods_return_payload_futures() -> Result<(), BpiError> {
425 let client = BpiClient::new()?;
426 let login = client.login();
427
428 assert_notice_future(login.notice(LoginNoticeParams::new(Mid::new(1_000_001)?)));
429 assert_log_future(login.log(LoginLogParams::new()));
430 assert_captcha_future(login.generate_captcha());
431 assert_qr_generate_future(login.qr_generate());
432 assert_qr_poll_future(login.qr_poll(LoginQrPollParams::new("sanitized-qrcode-key")?));
433 Ok(())
434 }
435
436 #[test]
437 fn login_read_info_contracts_match_endpoint_requests() -> Result<(), Box<dyn std::error::Error>>
438 {
439 for (endpoint, name, url) in READ_INFO_CONTRACTS {
440 let contract = read_info_contract(endpoint)?;
441
442 assert_eq!(contract.name, *name);
443 assert_eq!(contract.request.method, HttpMethod::Get);
444 assert_eq!(contract.request.url.as_str(), *url);
445 assert!(contract.request.query.is_empty());
446 assert_eq!(contract.cases.len(), 3);
447 assert_eq!(contract.cases[0].response.api_code, Some(-101));
448 assert_eq!(contract.cases[1].response.api_code, Some(0));
449 assert_eq!(contract.cases[2].response.api_code, Some(0));
450 }
451 Ok(())
452 }
453
454 #[test]
455 fn login_read_info_contracts_cover_vip_profile() -> Result<(), Box<dyn std::error::Error>> {
456 for (endpoint, _, _) in READ_INFO_CONTRACTS {
457 let contract = read_info_contract(endpoint)?;
458 let vip = contract
459 .cases
460 .iter()
461 .find(|case| case.name == "vip")
462 .ok_or_else(|| {
463 crate::BpiError::unsupported_response("missing vip contract case")
464 })?;
465
466 assert_eq!(vip.profile.as_deref(), Some("vip"));
467 assert!(vip.auth.requires_cookie());
468 assert_eq!(vip.response.api_code, Some(0));
469 }
470 Ok(())
471 }
472
473 #[test]
474 fn login_vip_info_contract_matches_endpoint_request() -> Result<(), Box<dyn std::error::Error>>
475 {
476 let contract = EndpointContract::from_slice(include_bytes!(
477 "../../tests/contracts/login/vip-info/contract.json"
478 ))?;
479
480 assert_eq!(contract.name, "login.vip_info");
481 assert_eq!(contract.request.method, HttpMethod::Get);
482 assert_eq!(
483 contract.request.url.as_str(),
484 "https://api.bilibili.com/x/vip/web/user/info"
485 );
486 assert_eq!(contract.cases.len(), 3);
487 Ok(())
488 }
489
490 #[test]
491 fn login_daily_reward_contract_matches_endpoint_request()
492 -> Result<(), Box<dyn std::error::Error>> {
493 let contract = endpoint_contract("daily-reward")?;
494
495 assert_eq!(contract.name, "login.daily_reward");
496 assert_eq!(contract.request.method, HttpMethod::Get);
497 assert_eq!(contract.request.url.as_str(), DAILY_REWARD_ENDPOINT);
498 assert_eq!(contract.cases.len(), 3);
499 assert_eq!(contract.cases[0].response.http_status, Some(412));
500 assert_eq!(contract.cases[1].response.api_code, Some(0));
501 assert_eq!(contract.cases[2].response.http_status, Some(412));
502 Ok(())
503 }
504
505 #[test]
506 fn login_safe_flow_contracts_match_module_client_endpoints() -> Result<(), BpiError> {
507 let client = BpiClient::new()?;
508 let login = client.login();
509 let cases = [
510 (
511 "notice/login-notice",
512 "login.notice",
513 login.notice_endpoint(),
514 ),
515 ("notice/login-log", "login.log", login.log_endpoint()),
516 (
517 "captcha/generate",
518 "login.captcha_generate",
519 login.captcha_generate_endpoint(),
520 ),
521 (
522 "qr/generate",
523 "login.qr_generate",
524 login.qr_generate_endpoint(),
525 ),
526 ("qr/poll", "login.qr_poll", login.qr_poll_endpoint()),
527 ];
528
529 for (path, name, url) in cases {
530 let contract = nested_contract(path)?;
531
532 assert_eq!(contract.name, name);
533 assert_eq!(contract.request.method, HttpMethod::Get);
534 assert_eq!(contract.request.url.as_str(), url);
535 }
536 Ok(())
537 }
538}