amagi 0.1.1

Rust SDK, CLI, and Web API service skeleton for multi-platform social web adapters.
Documentation
use std::{collections::BTreeMap, time::Duration};

use reqwest::{
    Client,
    header::{HeaderMap, HeaderName, HeaderValue},
};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};

use crate::{error::AppError, platforms::internal::random::now_unix_secs};

use super::super::sign::{build_wbi_query_from_url, extract_wbi_keys_from_nav_body};
use super::{
    BilibiliFetcher,
    helper::{append_query_segments, ensure_bilibili_success, extract_signature_suffix_from_query},
    requests,
};

impl BilibiliFetcher {
    pub(super) async fn fetch_json<T>(&self, url: &str) -> Result<T, AppError>
    where
        T: DeserializeOwned,
    {
        self.fetch_json_with_referer(url, None).await
    }

    pub(super) async fn post_json<T>(&self, url: &str, body: &Value) -> Result<T, AppError>
    where
        T: DeserializeOwned,
    {
        let value = inject_upstream_payload(self.post_json_value(url, body).await?);
        Ok(serde_json::from_value(value)?)
    }

    pub(super) async fn fetch_json_with_referer<T>(
        &self,
        url: &str,
        referer: Option<&str>,
    ) -> Result<T, AppError>
    where
        T: DeserializeOwned,
    {
        let value =
            inject_upstream_payload(self.fetch_json_value_with_referer(url, referer).await?);
        Ok(serde_json::from_value(value)?)
    }

    pub(super) async fn fetch_json_value(&self, url: &str) -> Result<Value, AppError> {
        self.fetch_json_value_with_referer(url, None).await
    }

    async fn post_json_value(&self, url: &str, body: &Value) -> Result<Value, AppError> {
        let response_body = self.send_post_json_request(url, body, None).await?;
        let value: Value = serde_json::from_str(&response_body)?;
        ensure_bilibili_success(url, &value)?;
        Ok(value)
    }

    async fn fetch_json_value_with_referer(
        &self,
        url: &str,
        referer: Option<&str>,
    ) -> Result<Value, AppError> {
        let body = self.send_text_request(url, referer).await?;
        let value: Value = serde_json::from_str(&body)?;
        ensure_bilibili_success(url, &value)?;
        Ok(value)
    }

    pub(super) async fn sign_wbi_url(&self, base_url: &str) -> Result<String, AppError> {
        let nav_url = requests::login_status(self.api_base_url.as_ref())?;
        let nav_body = self.send_text_request(&nav_url, None).await?;
        let keys = extract_wbi_keys_from_nav_body(&nav_body)?;
        let query = build_wbi_query_from_url(base_url, &keys, now_unix_secs())?;
        let suffix = extract_signature_suffix_from_query(&query);

        if base_url.contains('?') {
            Ok(format!("{base_url}&{suffix}"))
        } else {
            Ok(format!("{base_url}?{suffix}"))
        }
    }

    pub(super) async fn build_playurl_request(&self, base_url: &str) -> Result<String, AppError> {
        if self.cookie_header().trim().is_empty() {
            return Ok(append_query_segments(
                base_url,
                &["platform=html5".to_owned()],
            ));
        }

        let nav_url = requests::login_status(self.api_base_url.as_ref())?;
        let nav_body = self.send_text_request(&nav_url, None).await?;
        let nav_value: Value = serde_json::from_str(&nav_body)?;
        ensure_bilibili_success(&nav_url, &nav_value)?;

        let keys = extract_wbi_keys_from_nav_body(&nav_body)?;
        let signed_query = build_wbi_query_from_url(base_url, &keys, now_unix_secs())?;
        let is_vip = nav_value
            .get("data")
            .and_then(|value| value.get("vipStatus"))
            .and_then(Value::as_i64)
            == Some(1);

        let mut segments = if is_vip {
            vec!["fnval=4048".to_owned(), "fourk=1".to_owned()]
        } else {
            vec!["qn=64".to_owned(), "fnval=16".to_owned()]
        };
        segments.push(extract_signature_suffix_from_query(&signed_query));

        Ok(append_query_segments(base_url, &segments))
    }

    pub(super) fn cookie_header(&self) -> &str {
        self.request_profile
            .headers
            .get("Cookie")
            .or_else(|| self.request_profile.headers.get("cookie"))
            .map(String::as_str)
            .unwrap_or_default()
    }

    pub(super) async fn send_text_request(
        &self,
        url: &str,
        referer: Option<&str>,
    ) -> Result<String, AppError> {
        let (_, body) = self.send_text_request_with_headers(url, referer).await?;
        Ok(body)
    }

    pub(super) async fn send_bytes_request(
        &self,
        url: &str,
        referer: Option<&str>,
    ) -> Result<Vec<u8>, AppError> {
        let client = Client::builder()
            .timeout(Duration::from_millis(self.request_profile.timeout_ms))
            .build()?;
        let headers = self.build_headers(referer)?;
        let attempt_count = self.request_profile.max_retries.saturating_add(1);
        let mut last_error = None;

        for attempt in 0..attempt_count {
            match client.get(url).headers(headers.clone()).send().await {
                Ok(response) => {
                    let status = response.status();
                    let body = response.bytes().await.unwrap_or_default();

                    if !status.is_success() {
                        return Err(AppError::UpstreamResponse {
                            status: Some(status),
                            message: format!("request to {url} returned binary body"),
                        });
                    }

                    return Ok(body.to_vec());
                }
                Err(error) if attempt + 1 < attempt_count && is_retryable(&error) => {
                    last_error = Some(error);
                }
                Err(error) => return Err(error.into()),
            }
        }

        Err(last_error
            .map(AppError::from)
            .unwrap_or_else(|| AppError::UpstreamResponse {
                status: None,
                message: format!("request to {url} did not complete"),
            }))
    }

    async fn send_post_json_request(
        &self,
        url: &str,
        body: &Value,
        referer: Option<&str>,
    ) -> Result<String, AppError> {
        let client = Client::builder()
            .timeout(Duration::from_millis(self.request_profile.timeout_ms))
            .build()?;
        let headers = self.build_headers(referer)?;
        let attempt_count = self.request_profile.max_retries.saturating_add(1);
        let mut last_error = None;

        for attempt in 0..attempt_count {
            match client
                .post(url)
                .headers(headers.clone())
                .json(body)
                .send()
                .await
            {
                Ok(response) => {
                    let status = response.status();
                    let response_body = response.text().await.unwrap_or_default();

                    if !status.is_success() {
                        return Err(AppError::UpstreamResponse {
                            status: Some(status),
                            message: format!("request to {url} returned `{response_body}`"),
                        });
                    }

                    return Ok(response_body);
                }
                Err(error) if attempt + 1 < attempt_count && is_retryable(&error) => {
                    last_error = Some(error);
                }
                Err(error) => return Err(error.into()),
            }
        }

        Err(last_error
            .map(AppError::from)
            .unwrap_or_else(|| AppError::UpstreamResponse {
                status: None,
                message: format!("request to {url} did not complete"),
            }))
    }

    pub(super) async fn send_text_request_with_headers(
        &self,
        url: &str,
        referer: Option<&str>,
    ) -> Result<(HeaderMap, String), AppError> {
        let client = Client::builder()
            .timeout(Duration::from_millis(self.request_profile.timeout_ms))
            .build()?;
        let headers = self.build_headers(referer)?;
        let attempt_count = self.request_profile.max_retries.saturating_add(1);
        let mut last_error = None;

        for attempt in 0..attempt_count {
            match client.get(url).headers(headers.clone()).send().await {
                Ok(response) => {
                    let status = response.status();
                    let response_headers = response.headers().clone();
                    let body = response.text().await.unwrap_or_default();

                    if !status.is_success() {
                        return Err(AppError::UpstreamResponse {
                            status: Some(status),
                            message: format!("request to {url} returned `{body}`"),
                        });
                    }

                    return Ok((response_headers, body));
                }
                Err(error) if attempt + 1 < attempt_count && is_retryable(&error) => {
                    last_error = Some(error);
                }
                Err(error) => return Err(error.into()),
            }
        }

        Err(last_error
            .map(AppError::from)
            .unwrap_or_else(|| AppError::UpstreamResponse {
                status: None,
                message: format!("request to {url} did not complete"),
            }))
    }

    fn build_headers(&self, referer: Option<&str>) -> Result<HeaderMap, AppError> {
        let mut headers = self.request_profile.headers.clone();

        if let Some(referer) = referer {
            headers.insert("Referer".into(), referer.to_owned());
        }

        header_map_from_headers(&headers)
    }
}

fn header_map_from_headers(headers: &BTreeMap<String, String>) -> Result<HeaderMap, AppError> {
    let mut header_map = HeaderMap::new();

    for (name, value) in headers {
        let header_name = HeaderName::from_bytes(name.as_bytes())
            .map_err(|_| AppError::InvalidRequestConfig(format!("invalid header name `{name}`")))?;
        let header_value = HeaderValue::from_str(value).map_err(|_| {
            AppError::InvalidRequestConfig(format!("invalid header value for `{name}`"))
        })?;
        header_map.insert(header_name, header_value);
    }

    Ok(header_map)
}

fn is_retryable(error: &reqwest::Error) -> bool {
    error.is_connect() || error.is_timeout()
}

fn inject_upstream_payload(value: Value) -> Value {
    match value {
        Value::Object(mut object) => {
            let upstream_payload = normalize_upstream_payload(&object);
            object.insert("upstream_payload".to_owned(), upstream_payload);
            Value::Object(object)
        }
        other => other,
    }
}

fn normalize_upstream_payload(object: &Map<String, Value>) -> Value {
    object.get("data").cloned().unwrap_or(Value::Null)
}