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#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct UploadPicData {
15 pub image_url: String,
17 pub image_width: u64,
19 pub image_height: u64,
21 pub img_size: f64,
23}
24
25#[derive(Debug, Clone, Deserialize, Serialize)]
29pub struct CreateVoteData {
30 pub vote_id: u64,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize)]
38pub struct CreateDynamicData {
39 pub dynamic_id: u64,
41 pub dynamic_id_str: String,
43 }
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct DynamicContentItem {
51 #[serde(rename = "type")]
53 pub type_num: u8,
54 pub biz_id: Option<String>,
56 pub raw_text: String,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct DynamicPic {
63 pub img_src: String,
65 pub img_height: u64,
67 pub img_width: u64,
69 pub img_size: f64,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct DynamicTopic {
76 pub id: u64,
78 pub name: String,
80 pub from_source: Option<String>,
82 pub from_topic_id: Option<u64>,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct DynamicOption {
89 pub up_choose_comment: Option<u8>,
91 pub close_comment: Option<u8>,
93}
94
95#[derive(Debug, Clone, Deserialize, Serialize)]
97pub struct DynamicRequest {
98 pub attach_card: Option<serde_json::Value>,
100 pub content: DynamicContent,
102 pub meta: Option<serde_json::Value>,
104 pub scene: u8,
106 pub pics: Option<Vec<DynamicPic>>,
108 pub topic: Option<DynamicTopic>,
110 pub option: Option<DynamicOption>,
112}
113
114#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct DynamicContent {
117 pub contents: Vec<DynamicContentItem>,
118}
119
120#[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 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 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 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}