Skip to main content

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    /// # 文档
132    /// [查看API文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/dynamic)
133    ///
134    /// # 参数
135    ///
136    /// | 名称 | 类型 | 说明 |
137    /// | ---- | ---- | ---- |
138    /// | `file_path` | &Path | 图片文件路径 |
139    /// | `category` | `Option<&str>` | 图片类型,可选 `daily/draw/cos` |
140    pub async fn dynamic_upload_pic(
141        &self,
142        file_path: &Path,
143        category: Option<&str>
144    ) -> Result<BpiResponse<UploadPicData>, BpiError> {
145        let csrf = self.csrf()?;
146
147        let file = File::open(file_path).await.map_err(|_| BpiError::parse("打开文件失败"))?;
148        let stream = FramedRead::new(file, BytesCodec::new());
149        let body = Body::wrap_stream(stream);
150
151        let file_name = file_path
152            .file_name()
153            .ok_or_else(|| {
154                BpiError::parse("Invalid file path, cannot get file name".to_string())
155            })?;
156
157        let file_part = Part::stream(body)
158            .file_name(file_name.to_string_lossy().into_owned())
159            .mime_str("image/jpeg")?;
160
161        let mut form = Form::new().part("file_up", file_part).text("csrf", csrf.clone());
162
163        if let Some(cat) = category {
164            form = form.text("category", cat.to_string());
165        } else {
166            form = form.text("category", "daily".to_string());
167        }
168
169        form = form.text("biz", "new_dyn".to_string());
170
171        self
172            .post("https://api.bilibili.com/x/dynamic/feed/draw/upload_bfs")
173            .multipart(form)
174            .send_bpi("上传图片动态图片").await
175    }
176
177    /// 发布纯文本动态
178    ///
179    /// # 文档
180    /// [查看API文档](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
201            .post("https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/create")
202            .multipart(form)
203            .send_bpi("发布纯文本动态").await
204    }
205
206    /// 发表复杂动态
207    ///
208    /// # 文档
209    /// [查看API文档](https://github.com/SocialSisterYi/bilibili-API-collect/tree/master/docs/dynamic)
210    ///
211    /// # 参数
212    ///
213    /// | 名称 | 类型 | 说明 |
214    /// | ---- | ---- | ---- |
215    /// | `scene` | u8 | 动态类型:1 纯文本,2 带图,4 转发 |
216    /// | `contents` | `Vec<DynamicContentItem>` | 动态内容组件 |
217    /// | `pics` | `Option<Vec<DynamicPic>>`| 动态图片,最多 9 个 |
218    /// | `topic` | `Option<DynamicTopic>` | 话题 |
219    pub async fn dynamic_create_complex(
220        &self,
221        scene: u8,
222        contents: Vec<DynamicContentItem>,
223        pics: Option<Vec<DynamicPic>>,
224        topic: Option<DynamicTopic>
225    ) -> Result<BpiResponse<CreateComplexDynamicData>, BpiError> {
226        let csrf = self.csrf()?;
227
228        let dyn_req = DynamicRequest {
229            attach_card: None,
230            content: DynamicContent { contents },
231            meta: Some(
232                json!({
233                "app_meta": {
234                    "from": "create.dynamic.web",
235                    "mobi_app": "web"
236                }
237            })
238            ),
239            scene,
240            pics,
241            topic,
242            option: None,
243        };
244
245        let request_body = json!({
246            "dyn_req": dyn_req,
247        });
248
249        self
250            .post("https://api.bilibili.com/x/dynamic/feed/create/dyn")
251            .header("Content-Type", "application/json")
252            .query(&[("csrf", csrf)])
253            .body(request_body.to_string())
254            .send_bpi("发表复杂动态").await
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use tracing::info;
262
263    #[tokio::test]
264    async fn test_upload_dynamic_pic() -> Result<(), BpiError> {
265        let bpi = BpiClient::new();
266        let test_file = Path::new("./assets/test.jpg");
267        if !test_file.exists() {
268            return Err(BpiError::parse("Test file 'test.jpg' not found.".to_string()));
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    async fn test_create_text_dynamic() -> Result<(), BpiError> {
282        let bpi = BpiClient::new();
283        let content = format!("Rust Bilibili API 指南测试动态:{}", chrono::Local::now());
284
285        let resp = bpi.dynamic_create_text(&content).await?;
286        let data = resp.into_data()?;
287
288        info!("动态发布成功!动态ID: {}", data.dynamic_id_str);
289        assert!(!data.dynamic_id_str.is_empty());
290
291        Ok(())
292    }
293
294    #[tokio::test]
295    async fn test_create_complex_dynamic_text() -> Result<(), BpiError> {
296        let bpi = BpiClient::new();
297        let contents = vec![DynamicContentItem {
298            type_num: 1,
299            biz_id: None,
300            raw_text: format!("Rust Bilibili API 复杂动态文本测试:{}", 123),
301        }];
302
303        let resp = bpi.dynamic_create_complex(1, contents, None, None).await?;
304        let data = resp.into_data()?;
305
306        info!("复杂动态发布成功!动态ID: {}", data.dyn_id_str);
307        assert!(!data.dyn_id_str.is_empty());
308
309        Ok(())
310    }
311
312    #[tokio::test]
313    async fn test_create_complex_dynamic_with_pic() -> Result<(), BpiError> {
314        let bpi = BpiClient::new();
315        let test_file = Path::new("./assets/test.jpg");
316        if !test_file.exists() {
317            return Err(BpiError::parse("Test file 'test.jpg' not found.".to_string()));
318        }
319
320        let resp = bpi.dynamic_upload_pic(test_file, None).await?;
321        let data = resp.into_data()?;
322
323        info!("上传成功!图片 URL: {}", data.image_url);
324        let pics = vec![DynamicPic {
325            img_src: data.image_url,
326            img_height: data.image_height,
327            img_width: data.image_width,
328            img_size: data.img_size,
329        }];
330
331        let contents = vec![DynamicContentItem {
332            type_num: 1,
333            biz_id: None,
334            raw_text: format!("Rust Bilibili API 复杂动态图片测试:{}", 234),
335        }];
336
337        let resp = bpi.dynamic_create_complex(2, contents, Some(pics), None).await?;
338        let data = resp.into_data()?;
339
340        info!("复杂动态(带图)发布成功!动态ID: {}", data.dyn_id_str);
341        assert!(!data.dyn_id_str.is_empty());
342
343        Ok(())
344    }
345}