use super::models::{ArticleAuthor, ArticleCategory, ArticleMedia, ArticleStats};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleViewData {
pub act_id: i64,
pub apply_time: String,
pub attributes: Option<i32>,
#[serde(rename = "authenMark")]
pub authen_mark: Option<serde_json::Value>,
pub author: ArticleAuthor,
pub banner_url: String,
pub categories: Vec<ArticleCategory>,
pub category: ArticleCategory,
pub check_state: i32,
pub check_time: String,
pub content: String,
pub content_pic_list: Option<serde_json::Value>,
pub cover_avid: i64,
pub ctime: i64,
pub dispute: Option<serde_json::Value>,
pub dyn_id_str: String,
pub dynamic: Option<String>,
pub id: i64,
pub image_urls: Vec<String>,
pub is_like: bool,
pub keywords: String,
pub list: Option<ArticleList>,
pub media: ArticleMedia,
pub mtime: i64,
pub opus: Option<ArticleOpus>,
pub origin_image_urls: Vec<String>,
pub origin_template_id: i32,
pub original: i32,
pub private_pub: i32,
pub publish_time: i64,
pub reprint: i32,
pub state: i32,
pub stats: ArticleStats,
pub summary: String,
pub tags: Vec<ArticleTag>,
pub template_id: i32,
pub title: String,
pub top_video_info: Option<serde_json::Value>,
pub total_art_num: i64,
pub r#type: i32,
pub version_id: i64,
pub words: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorVip {
pub r#type: i32,
pub status: i32,
pub due_date: i64,
pub vip_pay_type: i32,
pub theme_type: i32,
pub label: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleList {
pub id: i64,
pub name: String,
pub image_url: String,
pub update_time: i64,
pub ctime: i64,
pub publish_time: i64,
pub summary: String,
pub words: i64,
pub read: i64,
pub articles_count: i32,
pub state: i32,
pub reason: String,
pub apply_time: String,
pub check_time: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleTag {
pub tid: i32,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArticleOpus {
#[serde(default)]
pub ops: Vec<OpusOperation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusOperation {
pub attribute: Option<OpusAttribute>,
pub insert: OpusInsert,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusAttribute {
pub align: Option<String>,
pub blockquote: Option<bool>,
pub bold: Option<bool>,
pub class: Option<String>,
pub color: Option<String>,
pub header: Option<i32>,
pub strike: Option<bool>,
pub link: Option<String>,
pub italic: Option<bool>,
pub list: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum OpusInsert {
Text(String),
Rich(Box<OpusRichInsert>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusRichInsert {
pub native_image: Option<OpusImage>,
pub cut_off: Option<OpusCutOff>,
pub video_card: Option<OpusVideoCard>,
pub article_card: Option<OpusArticleCard>,
pub vote_card: Option<OpusVoteCard>,
pub live_card: Option<OpusLiveCard>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusImage {
pub alt: String,
pub url: String,
pub width: i32,
pub height: i32,
pub size: i64,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusCutOff {
pub r#type: String,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusVideoCard {
pub alt: String,
pub height: i32,
pub id: String,
pub size: Option<serde_json::Value>,
pub status: String,
pub tid: f64,
pub url: String,
pub width: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusArticleCard {
pub alt: String,
pub height: i32,
pub id: String,
pub size: Option<serde_json::Value>,
pub status: String,
pub tid: i32,
pub url: String,
pub width: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusVoteCard {
pub alt: String,
pub height: i32,
pub id: String,
pub size: Option<serde_json::Value>,
pub status: String,
pub tid: i32,
pub url: String,
pub width: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpusLiveCard {
pub alt: String,
pub height: i32,
pub id: String,
pub size: Option<serde_json::Value>,
pub status: String,
pub tid: i32,
pub url: String,
pub width: i32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::article::params::ArticleViewParams;
use crate::probe::contract::HttpMethod;
use crate::probe::endpoint_contract::EndpointContract;
use crate::{ApiEnvelope, BpiClient, BpiError, BpiResult};
use std::mem;
const TEST_CVID: i64 = 2;
fn contract() -> BpiResult<EndpointContract> {
EndpointContract::from_slice(include_bytes!(
"../../tests/contracts/article/view/contract.json"
))
}
#[ignore = "legacy live API test; requires explicit BPI_LIVE_TEST review"]
#[tokio::test]
async fn test_article_view() -> Result<(), Box<BpiError>> {
let bpi = BpiClient::new().expect("client should build");
let params = ArticleViewParams::new(TEST_CVID)?;
let data = bpi.article().view(params).await?;
assert!(!data.title.is_empty());
assert!(!data.content.is_empty());
assert!(!data.author.name.is_empty());
Ok(())
}
#[test]
fn opus_insert_keeps_rich_payload_boxed() {
assert!(mem::size_of::<OpusInsert>() <= 64);
}
#[test]
fn article_view_contract_matches_endpoint_request() -> BpiResult<()> {
let contract = contract()?;
let params = ArticleViewParams::new(TEST_CVID)?;
assert_eq!(contract.name, "article.view");
assert_eq!(contract.request.method, HttpMethod::Get);
assert_eq!(
contract.request.url.as_str(),
"https://api.bilibili.com/x/article/view"
);
assert_eq!(
contract.request.query.get("id").map(String::as_str),
Some("2")
);
assert_eq!(
contract
.request
.query
.get("gaia_source")
.map(String::as_str),
Some("main_web")
);
assert_eq!(
params.query_pairs(),
vec![
("id", "2".to_string()),
("gaia_source", "main_web".to_string()),
]
);
assert_eq!(contract.cases.len(), 3);
assert_eq!(
contract.cases[0].response.error.as_deref(),
Some("wbi_risk_control")
);
assert_eq!(
contract.cases[1].response.rust_model.as_deref(),
Some("ArticleViewData")
);
Ok(())
}
#[test]
fn article_view_response_fixtures_parse_declared_model() -> BpiResult<()> {
for bytes in [
include_bytes!("../../tests/contracts/article/view/responses/normal.success.json")
.as_slice(),
include_bytes!("../../tests/contracts/article/view/responses/vip.success.json")
.as_slice(),
] {
let payload = ApiEnvelope::<ArticleViewData>::from_slice(bytes)?.into_payload()?;
assert_eq!(payload.id, TEST_CVID);
assert!(!payload.title.is_empty());
}
Ok(())
}
#[test]
fn article_view_anonymous_fixture_records_wbi_error() -> BpiResult<()> {
let err = ApiEnvelope::<serde_json::Value>::from_slice(include_bytes!(
"../../tests/contracts/article/view/responses/anonymous.error.json"
))?
.ensure_success()
.unwrap_err();
assert_eq!(err.code(), Some(-352));
Ok(())
}
fn local_probe_body(profile: &str) -> Option<serde_json::Value> {
let path = format!("target/bpi-probe-runs/article/read/view/{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 article_view_model_matches_local_probe_outputs_when_available() -> BpiResult<()> {
for profile in ["normal", "vip"] {
let Some(body) = local_probe_body(profile) else {
continue;
};
let payload =
serde_json::from_value::<ApiEnvelope<ArticleViewData>>(body)?.into_payload()?;
assert_eq!(payload.id, TEST_CVID);
}
Ok(())
}
}