use crate::client::{self, TREEHOLE_BASE};
use anyhow::{anyhow, Context, Result};
use pkuinfo_common::session::Store;
use reqwest::multipart;
use reqwest::Client;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
const APP_NAME: &str = "treehole";
#[derive(Debug, Deserialize)]
pub struct ApiResp<T> {
pub code: i64,
pub success: bool,
pub message: String,
#[serde(default)]
pub data: Option<T>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ListData<T> {
pub list: Vec<T>,
}
#[derive(Debug, Default, Deserialize)]
pub struct Hole {
pub pid: i64,
pub text: String,
pub timestamp: i64,
#[serde(default)]
pub reply: i64,
#[serde(default)]
pub likenum: i64,
#[serde(default)]
pub tread_num: i64,
#[serde(default)]
pub is_follow: i64,
#[serde(default)]
pub reward_cost: i64,
#[serde(default)]
pub tags_info: Vec<TagInfo>,
#[serde(default)]
pub is_top: i64,
#[serde(default)]
pub media_ids: String,
}
#[derive(Debug, Default)]
pub struct HoleWithComments {
pub hole: Hole,
pub list: Vec<Comment>,
pub total: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct HoleListItem {
#[serde(default)]
pub pid: i64,
#[serde(default)]
pub text: String,
#[serde(default)]
pub timestamp: i64,
#[serde(default)]
pub reply: i64,
#[serde(default)]
pub likenum: i64,
#[serde(default)]
pub tread_num: i64,
#[serde(default)]
pub is_follow: i64,
#[serde(default)]
pub is_top: i64,
#[serde(default)]
pub reward_cost: i64,
#[serde(default)]
pub tags_info: Vec<TagInfo>,
#[serde(default, alias = "comments")]
pub comment_list: Vec<Comment>,
#[serde(default)]
pub media_ids: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct Comment {
pub cid: i64,
pub text: String,
pub timestamp: i64,
#[serde(default)]
pub name_tag: String,
#[serde(default)]
pub is_lz: i64,
#[serde(default)]
pub quote: serde_json::Value,
#[serde(default)]
pub media_ids: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct TagInfo {
#[serde(default)]
pub tag_name: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct UserInfo {
#[serde(default)]
pub uid: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub newmsgcount: i64,
#[serde(default)]
pub action_remaining: i64,
#[serde(default)]
pub leaf_balance: i64,
#[serde(default)]
pub is_black: i64,
}
#[derive(Debug, Default, Deserialize)]
pub struct Message {
#[serde(default)]
pub title: String,
#[serde(default)]
pub content: String,
#[serde(default)]
pub pid: Option<i64>,
#[serde(default)]
pub is_read: i64,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct UnreadCount {
#[serde(default)]
pub count: i64,
}
#[derive(Debug, Default, Deserialize)]
pub struct Bookmark {
#[serde(default)]
pub id: i64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CourseScore {
#[serde(default)]
pub kcmc: String, #[serde(default)]
pub xf: String, #[serde(default)]
pub xqcj: String, #[serde(default)]
pub kclbmc: String, #[serde(default)]
pub xnd: String, #[serde(default)]
pub xq: String, }
#[derive(Debug, Clone, Deserialize)]
pub struct SemesterGpa {
pub gpa: String,
pub xndxq: String, }
#[derive(Debug)]
pub struct ScoreData {
pub courses: Vec<CourseScore>,
pub semester_gpas: Vec<SemesterGpa>,
pub overall_gpa: String,
pub total_credits: String,
}
#[derive(Debug, Clone)]
pub struct CourseSlot {
pub course_name: String,
pub style: String, }
#[derive(Debug, Clone)]
pub struct CourseRow {
pub time_num: String, pub slots: [Option<CourseSlot>; 7], }
#[derive(Debug, Clone, Deserialize)]
pub struct ClassTime {
pub name: String,
#[serde(default)]
pub time_period: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LabEvent {
#[serde(default, alias = "TITLE")]
pub title: String,
#[serde(default, alias = "DEPT")]
pub dept: String,
#[serde(default, alias = "LOCATION")]
pub location: Option<String>,
#[serde(default, alias = "HOST")]
pub host: Option<String>,
#[serde(default, alias = "START_TIME")]
pub start_time: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ActivityEvent {
#[serde(default)]
pub event_name: String,
#[serde(default)]
pub event_start_time: String,
#[serde(default)]
pub event_location: String,
#[serde(default)]
pub event_organizer: String,
#[serde(default)]
pub event_introduction: String,
#[serde(default)]
pub event_type_name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduleItem {
#[serde(default)]
pub title: String,
#[serde(default)]
pub content: String,
#[serde(default)]
pub start_time: String,
#[serde(default)]
pub end_time: String,
}
pub struct TreeholeApi {
client: Client,
token: String,
uuid: String,
config_dir: std::path::PathBuf,
}
impl TreeholeApi {
pub async fn from_session_verified() -> Result<Self> {
let store = Store::new(APP_NAME)?;
let session = store
.load_session()?
.ok_or_else(|| anyhow!("未登录,请先运行 `treehole login`"))?;
if session.is_expired() {
return Err(anyhow!("会话已过期,请重新运行 `treehole login`"));
}
let uuid = session
.extra
.get("full_uuid")
.and_then(|v| v.as_str())
.unwrap_or("Web_PKUHOLE_2.0.0_WEB_UUID_unknown")
.to_string();
let cookie_store = store.load_cookie_store()?;
let client = client::build(cookie_store.clone())?;
crate::verify::check_and_verify(&client, &session.token, &uuid).await?;
store.save_cookie_store(&cookie_store)?;
Ok(Self {
client,
token: session.token,
uuid,
config_dir: store.config_dir().to_path_buf(),
})
}
async fn course_token_exchange(&self) -> Result<()> {
if let Some(otp_code) = pkuinfo_common::otp::get_current_otp(&self.config_dir)? {
return self.course_token_exchange_with_otp(&otp_code).await;
}
self.course_token_exchange_with_sms().await
}
async fn course_token_exchange_with_otp(&self, otp_code: &str) -> Result<()> {
let url = format!("{TREEHOLE_BASE}/chapi/api/course/password_get_token");
let body = serde_json::json!({ "code": otp_code });
let resp = self
.client
.post(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.json(&body)
.send()
.await
.context("course/password_get_token 请求失败")?;
let api_resp: ApiResp<serde_json::Value> =
Self::parse_response(resp, "/course/password_get_token").await?;
if !api_resp.success {
return Err(anyhow!(
"换取课程授权码失败: {} (code {})",
api_resp.message,
api_resp.code
));
}
Ok(())
}
async fn course_token_exchange_with_sms(&self) -> Result<()> {
use colored::Colorize;
println!("{} 未绑定手机令牌,改用短信验证", "[course-auth]".cyan());
let send_url = format!("{TREEHOLE_BASE}/chapi/api/course/send_get_token_message");
let send_resp = self
.client
.post(&send_url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.header("content-type", "application/json")
.body("{}")
.send()
.await
.context("course/send_get_token_message 请求失败")?;
let send_api: ApiResp<serde_json::Value> =
Self::parse_response(send_resp, "/course/send_get_token_message").await?;
if !send_api.success {
return Err(anyhow!(
"发送短信失败: {} (code {})",
send_api.message,
send_api.code
));
}
println!("{} 短信验证码已发送", "[+]".green());
let sms_code = pkuinfo_common::credential::resolve_sms_code("请输入短信验证码: ")?;
let verify_url = format!("{TREEHOLE_BASE}/chapi/api/course/mobile_message_get_token");
let body = serde_json::json!({ "code": sms_code.trim() });
let verify_resp = self
.client
.post(&verify_url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.json(&body)
.send()
.await
.context("course/mobile_message_get_token 请求失败")?;
let verify_api: ApiResp<serde_json::Value> =
Self::parse_response(verify_resp, "/course/mobile_message_get_token").await?;
if !verify_api.success {
return Err(anyhow!(
"短信验证失败: {} (code {})\n\
提示: 可以运行 `treehole otp bind --send` 绑定手机令牌以避免每次都需要短信验证",
verify_api.message,
verify_api.code
));
}
Ok(())
}
async fn parse_response<T: DeserializeOwned + Default>(
resp: reqwest::Response,
path: &str,
) -> Result<ApiResp<T>> {
let status = resp.status();
let body = resp
.text()
.await
.with_context(|| format!("读取 {path} 响应失败"))?;
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(anyhow!("登录已失效,请重新运行 `treehole login`"));
}
serde_json::from_str(&body).with_context(|| {
if status.is_success() {
format!("{path} 响应解析失败: {}", &body[..body.len().min(100)])
} else {
format!("{path} 请求失败 (HTTP {status})")
}
})
}
async fn get<T: DeserializeOwned + Default>(&self, path: &str) -> Result<T> {
let url = format!("{TREEHOLE_BASE}/chapi/api/v3{path}");
let resp = self
.client
.get(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.send()
.await
.with_context(|| format!("GET {path} 失败"))?;
let api_resp: ApiResp<T> = Self::parse_response(resp, path).await?;
Self::check_resp(api_resp, path)
}
async fn post_json<T: DeserializeOwned + Default, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T> {
let url = format!("{TREEHOLE_BASE}/chapi/api/v3{path}");
let resp = self
.client
.post(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.json(body)
.send()
.await
.with_context(|| format!("POST {path} 失败"))?;
let api_resp: ApiResp<T> = Self::parse_response(resp, path).await?;
Self::check_resp(api_resp, path)
}
async fn post_action<B: Serialize>(&self, path: &str, body: &B) -> Result<String> {
let url = format!("{TREEHOLE_BASE}/chapi/api/v3{path}");
let resp = self
.client
.post(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.json(body)
.send()
.await
.with_context(|| format!("POST {path} 失败"))?;
let api_resp: ApiResp<serde_json::Value> = Self::parse_response(resp, path).await?;
if api_resp.code == 40002 {
return Err(anyhow!(
"需要短信验证,请重新运行 `treehole login -p` 完成验证"
));
}
if api_resp.success {
Ok(api_resp.message)
} else {
Err(anyhow!("{path}: {}", api_resp.message))
}
}
fn check_resp<T>(resp: ApiResp<T>, path: &str) -> Result<T> {
if resp.code == 40002 {
return Err(anyhow!(
"需要短信验证,请重新运行 `treehole login -p` 完成验证"
));
}
if resp.success {
resp.data.ok_or_else(|| anyhow!("{path}: 成功但 data 为空"))
} else {
Err(anyhow!("{path}: {}", resp.message))
}
}
async fn get_legacy<T: DeserializeOwned + Default>(&self, path: &str) -> Result<T> {
let url = format!("{TREEHOLE_BASE}/chapi/api{path}");
let send_once = || async {
let resp = self
.client
.get(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.send()
.await
.with_context(|| format!("GET {path} 失败"))?;
Self::parse_response::<T>(resp, path).await
};
let api_resp = send_once().await?;
if api_resp.code == 40077 {
self.course_token_exchange().await?;
let api_resp = send_once().await?;
return Self::check_resp(api_resp, path);
}
Self::check_resp(api_resp, path)
}
pub async fn list_holes(&self, page: u32, limit: u32) -> Result<Vec<HoleListItem>> {
let data: serde_json::Value = self
.get(&format!(
"/hole/list_comments?page={page}&limit={limit}&comment_limit=3&comment_stream=1"
))
.await?;
parse_hole_list_items(&data)
}
pub async fn list_follow(&self, page: u32, limit: u32) -> Result<Vec<HoleListItem>> {
let data: serde_json::Value = self
.get(&format!(
"/hole/list_comments?page={page}&limit={limit}&comment_limit=3&comment_stream=1&is_follow=1"
))
.await?;
parse_hole_list_items(&data)
}
pub async fn get_hole(&self, pid: i64) -> Result<HoleWithComments> {
let data: serde_json::Value = self
.get(&format!("/hole/one?pid={pid}&comment_stream=1"))
.await?;
parse_hole_with_comments(&data)
}
pub async fn my_holes(&self, page: u32, limit: u32) -> Result<Vec<Hole>> {
let data: ListData<Hole> = self
.get(&format!("/hole/my_list?page={page}&limit={limit}"))
.await?;
Ok(data.list)
}
pub async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result<Vec<Hole>> {
let encoded = urlencoding::encode(keyword);
let data: ListData<Hole> = self
.get(&format!(
"/hole/list?keyword={encoded}&page={page}&limit={limit}"
))
.await?;
Ok(data.list)
}
pub async fn create_hole(&self, req: &CreateHoleReq) -> Result<serde_json::Value> {
self.post_json("/hole/post", req).await
}
pub async fn create_comment(&self, req: &CreateCommentReq) -> Result<serde_json::Value> {
self.post_json("/comment/post", req).await
}
pub async fn praise_hole(&self, pid: i64) -> Result<String> {
self.post_action("/hole/praise", &serde_json::json!({"pid": pid}))
.await
}
pub async fn tread_hole(&self, pid: i64) -> Result<String> {
self.post_action("/hole/tread", &serde_json::json!({"pid": pid}))
.await
}
pub async fn follow_hole(&self, pid: i64) -> Result<String> {
self.post_action(
"/hole/attention",
&serde_json::json!({"pid": pid, "switch": 1}),
)
.await
}
pub async fn unfollow_hole(&self, pid: i64) -> Result<String> {
self.post_action("/hole/attention_cancel", &serde_json::json!({"pid": pid}))
.await
}
pub async fn star_hole(&self, pid: i64, bookmark_id: Option<i64>) -> Result<String> {
let mut body = serde_json::json!({"pid": pid, "switch": 1});
if let Some(bid) = bookmark_id {
body["bookmark_id"] = serde_json::json!(bid);
}
self.post_action("/hole/attention", &body).await
}
pub async fn list_bookmark_groups(&self) -> Result<Vec<Bookmark>> {
let data: ListData<Bookmark> = self.get("/bookmark/list?page=1&limit=200").await?;
Ok(data.list)
}
pub async fn report_hole(&self, pid: i64, reason: &str) -> Result<String> {
self.post_action(
"/hole/report",
&serde_json::json!({"pid": pid, "reason": reason}),
)
.await
}
pub async fn list_messages(&self, page: u32, limit: u32) -> Result<Vec<Message>> {
let data: ListData<Message> = self
.get(&format!("/message/index?page={page}&limit={limit}"))
.await?;
Ok(data.list)
}
pub async fn unread_count(&self) -> Result<(i64, i64)> {
let int: UnreadCount = self.get("/message/un_read?message_type=int_msg").await?;
let sys: UnreadCount = self.get("/message/un_read?message_type=sys_msg").await?;
Ok((int.count, sys.count))
}
pub async fn mark_read(&self, ids: &[i64]) -> Result<String> {
self.post_action("/message/set_read", &serde_json::json!({"ids": ids}))
.await
}
pub async fn user_info(&self) -> Result<UserInfo> {
let path = "/users/info";
let url = format!("{TREEHOLE_BASE}/chapi/api/v3{path}");
let resp = self
.client
.post(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.json(&serde_json::json!({}))
.send()
.await
.context("获取用户信息失败")?;
let api_resp: ApiResp<UserInfo> = Self::parse_response(resp, path).await?;
if api_resp.success {
api_resp.data.ok_or_else(|| anyhow!("用户信息为空"))
} else {
Err(anyhow!("获取用户信息失败: {}", api_resp.message))
}
}
pub async fn get_scores(&self) -> Result<ScoreData> {
let data: serde_json::Value = self.get_legacy("/course/score_v2").await?;
let score = data
.get("score")
.ok_or_else(|| anyhow!("成绩数据缺少 score"))?;
let gpa_section = data.get("gpa").ok_or_else(|| anyhow!("成绩数据缺少 gpa"))?;
let courses: Vec<CourseScore> = score
.get("cjxx")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| serde_json::from_value(c.clone()).ok())
.collect()
})
.unwrap_or_default();
let overall_gpa = score
.get("gpa")
.and_then(|g| g.get("gpa"))
.and_then(|v| v.as_str())
.unwrap_or("N/A")
.to_string();
let total_credits = score
.get("gpa")
.and_then(|g| g.get("xxxf"))
.and_then(|v| v.as_str())
.unwrap_or("0")
.to_string();
let semester_gpas: Vec<SemesterGpa> = gpa_section
.get("data")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|g| serde_json::from_value(g.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(ScoreData {
courses,
semester_gpas,
overall_gpa,
total_credits,
})
}
pub async fn get_coursetable(&self) -> Result<Vec<CourseRow>> {
let data: serde_json::Value = self.get_legacy("/getCoursetable_v2").await?;
let courses = data
.get("course")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("课表数据缺少 course"))?;
let days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
let rows = courses
.iter()
.map(|row| {
let time_num = val_str(row, "timeNum");
let mut slots: [Option<CourseSlot>; 7] = Default::default();
for (i, day) in days.iter().enumerate() {
if let Some(d) = row.get(*day) {
let name = val_str(d, "courseName");
if !name.is_empty() {
let parts: Vec<&str> = name.split("<br>").collect();
let course_name = parts.first().unwrap_or(&"").to_string();
let style = val_str(d, "sty");
slots[i] = Some(CourseSlot { course_name, style });
}
}
}
CourseRow { time_num, slots }
})
.collect();
Ok(rows)
}
pub async fn get_class_times(&self) -> Result<Vec<ClassTime>> {
let data: serde_json::Value = self
.post_json("/classtimes/user_class_times", &serde_json::json!({}))
.await?;
let list: Vec<ClassTime> = data
.get("list")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| serde_json::from_value(c.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(list)
}
pub async fn list_lab_events(&self, start: &str, end: &str) -> Result<Vec<LabEvent>> {
let data: serde_json::Value = self
.get(&format!("/lab_events?startDate={start}&endDate={end}"))
.await?;
let events: Vec<LabEvent> = data
.get("events")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| serde_json::from_value(e.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(events)
}
pub async fn list_activity_events(
&self,
start: &str,
end: &str,
page: u32,
limit: u32,
) -> Result<Vec<ActivityEvent>> {
let data: ListData<ActivityEvent> = self
.get(&format!(
"/events/list?startTime={start}&endTime={end}&page={page}&limit={limit}"
))
.await?;
Ok(data.list)
}
pub async fn upload_image(&self, path: &std::path::Path) -> Result<String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image.jpg")
.to_string();
let data = tokio::fs::read(path)
.await
.with_context(|| format!("读取图片文件失败: {}", path.display()))?;
let mime = match path.extension().and_then(|e| e.to_str()) {
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
_ => "image/jpeg",
};
let part = multipart::Part::bytes(data)
.file_name(file_name)
.mime_str(mime)?;
let form = multipart::Form::new().part("file", part);
let url = format!("{TREEHOLE_BASE}/chapi/api/v3/media/uploadImage");
let resp = self
.client
.post(&url)
.header("authorization", format!("Bearer {}", self.token))
.header("uuid", &self.uuid)
.multipart(form)
.send()
.await
.context("上传图片失败")?;
let api_resp: ApiResp<serde_json::Value> =
Self::parse_response(resp, "/media/uploadImage").await?;
if !api_resp.success {
return Err(anyhow!("上传图片失败: {}", api_resp.message));
}
let data = api_resp
.data
.ok_or_else(|| anyhow!("上传成功但返回数据为空"))?;
let id = data
.get("id")
.and_then(|v| {
v.as_i64()
.map(|n| n.to_string())
.or_else(|| v.as_str().map(String::from))
})
.ok_or_else(|| anyhow!("上传响应缺少 id 字段: {data}"))?;
Ok(id)
}
pub async fn upload_images(&self, paths: &[std::path::PathBuf]) -> Result<String> {
let mut ids = Vec::new();
for path in paths {
let id = self.upload_image(path).await?;
ids.push(id);
}
Ok(ids.join(","))
}
pub async fn list_schedules(&self, start: &str, end: &str) -> Result<Vec<ScheduleItem>> {
let data: serde_json::Value = self
.get(&format!(
"/schedules/weeklySchedules?start_time={start}&end_time={end}"
))
.await?;
if let Some(arr) = data.as_array() {
Ok(arr
.iter()
.filter_map(|s| serde_json::from_value(s.clone()).ok())
.collect())
} else if let Some(list) = data.get("list").and_then(|v| v.as_array()) {
Ok(list
.iter()
.filter_map(|s| serde_json::from_value(s.clone()).ok())
.collect())
} else {
Ok(vec![])
}
}
}
#[derive(Debug, Serialize)]
pub struct CreateHoleReq {
pub text: String,
pub r#type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags_ids: Option<String>,
#[serde(default)]
pub anonymous: i64,
#[serde(default)]
pub fold: i64,
#[serde(default)]
pub reward_cost: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_ids: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CreateCommentReq {
pub pid: i64,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment_id: Option<i64>,
#[serde(default)]
pub anonymous: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_ids: Option<String>,
}
fn val_i64(v: &serde_json::Value, key: &str) -> i64 {
v.get(key).and_then(|x| x.as_i64()).unwrap_or(0)
}
fn val_str(v: &serde_json::Value, key: &str) -> String {
v.get(key)
.and_then(|x| x.as_str())
.map(|s| s.to_string())
.unwrap_or_default()
}
fn parse_tags(v: &serde_json::Value) -> Vec<TagInfo> {
v.get("tags_info")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.map(|t| TagInfo {
tag_name: val_str(t, "tag_name"),
})
.collect()
})
.unwrap_or_default()
}
fn parse_comment(c: &serde_json::Value) -> Comment {
Comment {
cid: val_i64(c, "cid"),
text: val_str(c, "text"),
timestamp: val_i64(c, "timestamp"),
name_tag: val_str(c, "name_tag"),
is_lz: val_i64(c, "is_lz"),
quote: c.get("quote").cloned().unwrap_or(serde_json::Value::Null),
media_ids: val_str(c, "media_ids"),
}
}
fn parse_comments(v: &serde_json::Value, key: &str) -> Vec<Comment> {
v.get(key)
.and_then(|c| c.as_array())
.map(|arr| arr.iter().map(parse_comment).collect())
.unwrap_or_default()
}
fn parse_hole(v: &serde_json::Value) -> Hole {
Hole {
pid: val_i64(v, "pid"),
text: val_str(v, "text"),
timestamp: val_i64(v, "timestamp"),
reply: val_i64(v, "reply"),
likenum: val_i64(v, "likenum"),
tread_num: val_i64(v, "tread_num"),
is_follow: val_i64(v, "is_follow"),
is_top: val_i64(v, "is_top"),
reward_cost: val_i64(v, "reward_cost"),
tags_info: parse_tags(v),
media_ids: val_str(v, "media_ids"),
}
}
fn parse_hole_list_items(data: &serde_json::Value) -> Result<Vec<HoleListItem>> {
let list = data
.get("list")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow::anyhow!("响应缺少 list 数组"))?;
let items = list
.iter()
.map(|v| HoleListItem {
pid: val_i64(v, "pid"),
text: val_str(v, "text"),
timestamp: val_i64(v, "timestamp"),
reply: val_i64(v, "reply"),
likenum: val_i64(v, "likenum"),
tread_num: val_i64(v, "tread_num"),
is_follow: val_i64(v, "is_follow"),
is_top: val_i64(v, "is_top"),
reward_cost: val_i64(v, "reward_cost"),
tags_info: parse_tags(v),
comment_list: parse_comments(v, "comment_list"),
media_ids: val_str(v, "media_ids"),
})
.collect();
Ok(items)
}
fn parse_hole_with_comments(data: &serde_json::Value) -> Result<HoleWithComments> {
let hole_val = data
.get("hole")
.ok_or_else(|| anyhow::anyhow!("响应缺少 hole"))?;
Ok(HoleWithComments {
hole: parse_hole(hole_val),
list: parse_comments(data, "list"),
total: data.get("total").and_then(|x| x.as_i64()),
})
}