bpi_rs/dynamic/
publish.rs

1use crate::{BilibiliRequest, BpiClient, BpiError, BpiResponse};
2use reqwest::Body;
3use reqwest::multipart::{Form, Part};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::path::Path;
7use tokio::fs::File;
8use tokio_util::codec::{BytesCodec, FramedRead};
9
10// --- 图片上传 API 结构体 ---
11
12/// 图片上传响应数据
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct UploadPicData {
15    /// 已上传图片 URL
16    pub image_url: String,
17    /// 已上传图片宽度
18    pub image_width: u64,
19    /// 已上传图片高度
20    pub image_height: u64,
21    /// 已上传图片大小k
22    pub img_size: f64,
23}
24
25// --- 创建投票 API 结构体 ---
26
27/// 创建投票响应数据
28#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct CreateVoteData {
30    /// 投票 ID
31    pub vote_id: u64,
32}
33
34// --- 发布纯文本动态 API 结构体 ---
35
36/// 纯文本动态响应数据
37#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct CreateDynamicData {
39    /// 动态 ID
40    pub dynamic_id: u64,
41    /// 动态 ID 字符串格式
42    pub dynamic_id_str: String,
43    // ... 其他字段
44}
45
46// 复杂动态
47
48/// 动态内容组件
49#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct DynamicContentItem {
51    /// 组件类型 ID,例如 @用户
52    #[serde(rename = "type")]
53    pub type_num: u8,
54    /// 组件的内容 ID,例如用户的 mid
55    pub biz_id: Option<String>,
56    /// 文本内容
57    pub raw_text: String,
58}
59
60/// 动态图片
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct DynamicPic {
63    /// 图片 URL
64    pub img_src: String,
65    /// 图片高度
66    pub img_height: u64,
67    /// 图片宽度
68    pub img_width: u64,
69    /// 图片大小 (KB)
70    pub img_size: f64,
71}
72
73/// 动态话题
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct DynamicTopic {
76    /// 话题 ID
77    pub id: u64,
78    /// 话题名
79    pub name: String,
80    /// 来源,非必要
81    pub from_source: Option<String>,
82    /// 来源话题 ID,非必要
83    pub from_topic_id: Option<u64>,
84}
85
86/// 动态互动设置
87#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct DynamicOption {
89    /// 开启精选评论
90    pub up_choose_comment: Option<u8>,
91    /// 关闭评论
92    pub close_comment: Option<u8>,
93}
94
95/// 复杂动态请求体
96#[derive(Debug, Clone, Deserialize, Serialize)]
97pub struct DynamicRequest {
98    /// 特殊卡片,非必要
99    pub attach_card: Option<serde_json::Value>,
100    /// 动态内容
101    pub content: DynamicContent,
102    /// 元信息,非必要
103    pub meta: Option<serde_json::Value>,
104    /// 动态类型
105    pub scene: u8,
106    /// 携带的图片
107    pub pics: Option<Vec<DynamicPic>>,
108    /// 话题
109    pub topic: Option<DynamicTopic>,
110    /// 互动设置
111    pub option: Option<DynamicOption>,
112}
113
114/// 动态内容
115#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct DynamicContent {
117    pub contents: Vec<DynamicContentItem>,
118}
119
120/// 复杂动态响应数据
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct CreateComplexDynamicData {
123    pub dyn_id: u64,
124    pub dyn_id_str: String,
125    pub dyn_type: u8,
126}
127
128impl BpiClient {
129    /// 为图片动态上传图片
130    ///
131    /// 文档: https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/dynamic
132    ///
133    /// 参数
134    ///
135    /// | 名称 | 类型 | 说明 |
136    /// | ---- | ---- | ---- |
137    /// | `file_path` | &Path | 图片文件路径 |
138    /// | `category` | Option<&str> | 图片类型,可选 `daily/draw/cos` |
139    pub async fn dynamic_upload_pic(
140        &self,
141        file_path: &Path,
142        category: Option<&str>,
143    ) -> Result<BpiResponse<UploadPicData>, BpiError> {
144        let csrf = self.csrf()?;
145
146        let file = File::open(file_path)
147            .await
148            .map_err(|_| BpiError::parse("打开文件失败"))?;
149        let stream = FramedRead::new(file, BytesCodec::new());
150        let body = Body::wrap_stream(stream);
151
152        let file_name = file_path.file_name().ok_or_else(|| {
153            BpiError::parse("Invalid file path, cannot get file name".to_string())
154        })?;
155
156        let file_part = Part::stream(body)
157            .file_name(file_name.to_string_lossy().into_owned())
158            .mime_str("image/jpeg")?;
159
160        let mut form = Form::new()
161            .part("file_up", file_part)
162            .text("csrf", csrf.clone());
163
164        if let Some(cat) = category {
165            form = form.text("category", cat.to_string());
166        } else {
167            form = form.text("category", "daily".to_string());
168        }
169
170        form = form.text("biz", "new_dyn".to_string());
171
172        self.post("https://api.bilibili.com/x/dynamic/feed/draw/upload_bfs")
173            .multipart(form)
174            .send_bpi("上传图片动态图片")
175            .await
176    }
177
178    /// 发布纯文本动态
179    ///
180    /// 文档: https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/dynamic
181    ///
182    /// 参数
183    ///
184    /// | 名称 | 类型 | 说明 |
185    /// | ---- | ---- | ---- |
186    /// | `content` | &str | 动态内容 |
187    pub async fn dynamic_create_text(
188        &self,
189        content: &str,
190    ) -> Result<BpiResponse<CreateDynamicData>, BpiError> {
191        let csrf = self.csrf()?;
192        let form = Form::new()
193            .text("dynamic_id", "0")
194            .text("type", "4")
195            .text("rid", "0")
196            .text("content", content.to_string())
197            .text("csrf", csrf.clone())
198            .text("csrf_token", csrf);
199
200        self.post("https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/create")
201            .multipart(form)
202            .send_bpi("发布纯文本动态")
203            .await
204    }
205
206    /// 发表复杂动态
207    ///
208    /// 文档: https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/dynamic
209    ///
210    /// 参数
211    ///
212    /// | 名称 | 类型 | 说明 |
213    /// | ---- | ---- | ---- |
214    /// | `scene` | u8 | 动态类型:1 纯文本,2 带图,4 转发 |
215    /// | `contents` | Vec<DynamicContentItem> | 动态内容组件 |
216    /// | `pics` | Option<Vec<DynamicPic>> | 动态图片,最多 9 个 |
217    /// | `topic` | Option<DynamicTopic> | 话题 |
218    pub async fn dynamic_create_complex(
219        &self,
220        scene: u8,
221        contents: Vec<DynamicContentItem>,
222        pics: Option<Vec<DynamicPic>>,
223        topic: Option<DynamicTopic>,
224    ) -> Result<BpiResponse<CreateComplexDynamicData>, BpiError> {
225        let csrf = self.csrf()?;
226
227        let dyn_req = DynamicRequest {
228            attach_card: None,
229            content: DynamicContent { contents },
230            meta: Some(json!({
231                "app_meta": {
232                    "from": "create.dynamic.web",
233                    "mobi_app": "web"
234                }
235            })),
236            scene,
237            pics,
238            topic,
239            option: None,
240        };
241
242        let request_body = json!({
243            "dyn_req": dyn_req,
244        });
245
246        self.post("https://api.bilibili.com/x/dynamic/feed/create/dyn")
247            .header("Content-Type", "application/json")
248            .query(&[("csrf", csrf)])
249            .body(request_body.to_string())
250            .send_bpi("发表复杂动态")
251            .await
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use tracing::info;
259
260    #[tokio::test]
261
262    async fn test_upload_dynamic_pic() -> Result<(), BpiError> {
263        let bpi = BpiClient::new();
264        let test_file = Path::new("./assets/test.jpg");
265        if !test_file.exists() {
266            return Err(BpiError::parse(
267                "Test file 'test.jpg' not found.".to_string(),
268            ));
269        }
270
271        let resp = bpi.dynamic_upload_pic(test_file, None).await?;
272        let data = resp.into_data()?;
273
274        info!("上传成功!图片 URL: {}", data.image_url);
275        assert!(!data.image_url.is_empty());
276
277        Ok(())
278    }
279
280    #[tokio::test]
281
282    async fn test_create_text_dynamic() -> Result<(), BpiError> {
283        let bpi = BpiClient::new();
284        let content = format!("Rust Bilibili API 指南测试动态:{}", chrono::Local::now());
285
286        let resp = bpi.dynamic_create_text(&content).await?;
287        let data = resp.into_data()?;
288
289        info!("动态发布成功!动态ID: {}", data.dynamic_id_str);
290        assert!(!data.dynamic_id_str.is_empty());
291
292        Ok(())
293    }
294
295    #[tokio::test]
296
297    async fn test_create_complex_dynamic_text() -> Result<(), BpiError> {
298        let bpi = BpiClient::new();
299        let contents = vec![DynamicContentItem {
300            type_num: 1,
301            biz_id: None,
302            raw_text: format!("Rust Bilibili API 复杂动态文本测试:{}", 123),
303        }];
304
305        let resp = bpi.dynamic_create_complex(1, contents, None, None).await?;
306        let data = resp.into_data()?;
307
308        info!("复杂动态发布成功!动态ID: {}", data.dyn_id_str);
309        assert!(!data.dyn_id_str.is_empty());
310
311        Ok(())
312    }
313
314    #[tokio::test]
315
316    async fn test_create_complex_dynamic_with_pic() -> Result<(), BpiError> {
317        let bpi = BpiClient::new();
318        let test_file = Path::new("./assets/test.jpg");
319        if !test_file.exists() {
320            return Err(BpiError::parse(
321                "Test file 'test.jpg' not found.".to_string(),
322            ));
323        }
324
325        let resp = bpi.dynamic_upload_pic(test_file, None).await?;
326        let data = resp.into_data()?;
327
328        info!("上传成功!图片 URL: {}", data.image_url);
329        let pics = vec![DynamicPic {
330            img_src: data.image_url,
331            img_height: data.image_height,
332            img_width: data.image_width,
333            img_size: data.img_size,
334        }];
335
336        let contents = vec![DynamicContentItem {
337            type_num: 1,
338            biz_id: None,
339            raw_text: format!("Rust Bilibili API 复杂动态图片测试:{}", 234),
340        }];
341
342        let resp = bpi
343            .dynamic_create_complex(2, contents, Some(pics), None)
344            .await?;
345        let data = resp.into_data()?;
346
347        info!("复杂动态(带图)发布成功!动态ID: {}", data.dyn_id_str);
348        assert!(!data.dyn_id_str.is_empty());
349
350        Ok(())
351    }
352}