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(
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 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 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}