Skip to main content

bpi_rs/login/
client.rs

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/// Login domain API client.
28#[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    /// Fetches the current session's navigation/login state.
99    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    /// Fetches the current authenticated user's following, follower, and dynamic counts.
107    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    /// Fetches the current authenticated account's coin balance.
115    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    /// Fetches today's experience gained from coin operations.
123    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    /// Fetches the current authenticated account's daily reward completion state.
131    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    /// Fetches the current authenticated account's profile.
139    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    /// Fetches the current authenticated account's VIP state.
147    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    /// Fetches a specific login notice for an authenticated account.
155    pub async fn notice(&self, params: LoginNoticeParams) -> BpiResult<LoginNoticeData> {
156        self.client
157            .get(NOTICE_ENDPOINT)
158            .with_bilibili_headers()
159            .query(&params.query_pairs())
160            .send_bpi_payload("login.notice")
161            .await
162    }
163
164    /// Fetches recent login log entries for an authenticated account.
165    pub async fn log(&self, params: LoginLogParams) -> BpiResult<LoginLogData> {
166        self.client
167            .get(LOG_ENDPOINT)
168            .with_bilibili_headers()
169            .query(&params.query_pairs())
170            .send_bpi_payload("login.log")
171            .await
172    }
173
174    /// Generates a Geetest captcha challenge for login flows.
175    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    /// Generates a QR login URL and temporary polling key.
193    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    /// Polls the QR login state.
202    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(&params.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}