bpi-rs 0.2.0

Bilibili API client library for Rust
Documentation
use serde::{Deserialize, Serialize};

#[cfg(test)]
use crate::{BpiClient, BpiError};

#[cfg(test)]
const CAPTCHA_GENERATE_ENDPOINT: &str = "https://passport.bilibili.com/x/passport-login/captcha";
#[cfg(test)]
const CAPTCHA_SOURCE: &str = "main_web";

#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeetestData {
    #[serde(rename = "type")]
    pub type_field: String,
    pub token: String,
    pub geetest: Geetest,
    pub tencent: Tencent,
}

#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Geetest {
    pub challenge: String,
    pub gt: String,
}

#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tencent {
    pub appid: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GenerateCaptcha {
    pub token: String,
    pub gt: String,
    pub challenge: String,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ApiEnvelope;
    use crate::probe::contract::HttpMethod;
    use crate::probe::endpoint_contract::EndpointContract;

    fn local_captcha_probe_body(profile: &str) -> Option<serde_json::Value> {
        let path = format!("target/bpi-probe-runs/login/captcha/generate/{profile}.response.json");
        let bytes = std::fs::read(path).ok()?;
        let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
        value
            .get("response")
            .and_then(|response| response.get("body"))
            .cloned()
    }

    #[test]
    fn captcha_generate_contract_matches_endpoint_request() -> Result<(), BpiError> {
        let contract = EndpointContract::from_slice(include_bytes!(
            "../../../tests/contracts/login/captcha/generate/contract.json"
        ))?;

        assert_eq!(contract.name, "login.captcha_generate");
        assert_eq!(contract.request.method, HttpMethod::Get);
        assert_eq!(contract.request.url.as_str(), CAPTCHA_GENERATE_ENDPOINT);
        assert_eq!(
            contract.request.query.get("source").map(String::as_str),
            Some(CAPTCHA_SOURCE)
        );
        assert!(!contract.request.auth.requires_cookie());
        assert_eq!(contract.cases.len(), 3);
        assert!(contract.cases.iter().all(|case| {
            case.response.api_code == Some(0)
                && case.response.rust_model.as_deref() == Some("GeetestData")
        }));
        Ok(())
    }

    #[test]
    fn captcha_generate_contract_covers_all_profiles() -> Result<(), BpiError> {
        let contract = EndpointContract::from_slice(include_bytes!(
            "../../../tests/contracts/login/captcha/generate/contract.json"
        ))?;

        let anonymous = &contract.cases[0];
        let normal = &contract.cases[1];
        let vip = &contract.cases[2];

        assert_eq!(anonymous.profile.as_deref(), Some("anonymous"));
        assert!(!anonymous.auth.requires_cookie());
        assert_eq!(normal.profile.as_deref(), Some("normal"));
        assert!(normal.auth.requires_cookie());
        assert_eq!(vip.profile.as_deref(), Some("vip"));
        assert!(vip.auth.requires_cookie());
        Ok(())
    }

    #[test]
    fn captcha_response_fixture_parses_declared_model() -> Result<(), BpiError> {
        let data = ApiEnvelope::<GeetestData>::from_slice(include_bytes!(
            "../../../tests/contracts/login/captcha/generate/responses/success.json"
        ))?
        .into_payload()?;

        assert_eq!(data.type_field, "geetest");
        assert!(!data.token.trim().is_empty());
        assert!(!data.geetest.gt.trim().is_empty());
        assert!(!data.geetest.challenge.trim().is_empty());
        Ok(())
    }

    #[test]
    fn generate_captcha_projection_uses_geetest_payload() -> Result<(), BpiError> {
        let data = ApiEnvelope::<GeetestData>::from_slice(include_bytes!(
            "../../../tests/contracts/login/captcha/generate/responses/success.json"
        ))?
        .into_payload()?;

        let captcha = GenerateCaptcha {
            token: data.token,
            gt: data.geetest.gt,
            challenge: data.geetest.challenge,
        };

        assert!(captcha.token.starts_with("sanitized-"));
        assert!(captcha.gt.starts_with("sanitized-"));
        assert!(captcha.challenge.starts_with("sanitized-"));
        Ok(())
    }

    #[test]
    fn captcha_model_matches_local_probe_outputs_when_available() -> Result<(), BpiError> {
        for profile in ["anonymous", "normal", "vip"] {
            let Some(body) = local_captcha_probe_body(profile) else {
                continue;
            };

            let data = serde_json::from_value::<ApiEnvelope<GeetestData>>(body)?.into_payload()?;

            assert_eq!(data.type_field, "geetest");
            assert_eq!(data.token.len(), 32);
            assert_eq!(data.geetest.gt.len(), 32);
            assert_eq!(data.geetest.challenge.len(), 32);
        }
        Ok(())
    }
}

#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_generate_captcha() {
    let bpi = BpiClient::new().expect("client should build");
    match bpi.login().generate_captcha().await {
        Ok(captcha) => {
            tracing::info!("验证码请求成功!");
            tracing::info!("Token: {}", captcha.token);
            tracing::info!("GT: {}", captcha.gt);
            tracing::info!("Challenge: {}", captcha.challenge);
        }
        Err(e) => {
            tracing::info!("验证码请求失败: {}", e);
        }
    }
}