use crate::types::{Comment, FeedItem, MediaItem, Note, Notification, SearchNote, UserNote};
use serde_json::Value;
use tail_fin_common::TailFinError;
pub fn check_page_status(val: &Value) -> Result<(), TailFinError> {
if val
.get("loginWall")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err(TailFinError::AuthRequired);
}
if val
.get("notFound")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return Err(TailFinError::Api(
"XHS: content not found or deleted".into(),
));
}
Ok(())
}
pub fn parse_note(val: &Value) -> Result<Note, TailFinError> {
let note = val.get("note").ok_or_else(|| {
let error = val
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
TailFinError::Parse(format!("XHS: failed to extract note data: {}", error))
})?;
Ok(Note {
id: note
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
title: note
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
content: note
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
author: note
.get("author")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
author_id: note
.get("authorId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
likes: note.get("likes").and_then(|v| v.as_u64()).unwrap_or(0),
collects: note.get("collects").and_then(|v| v.as_u64()).unwrap_or(0),
comments: note.get("comments").and_then(|v| v.as_u64()).unwrap_or(0),
shares: note.get("shares").and_then(|v| v.as_u64()).unwrap_or(0),
tags: note
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
images: note
.get("images")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
video: note.get("video").and_then(|v| v.as_str()).map(String::from),
published_at: note
.get("publishedAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
note_type: note
.get("noteType")
.and_then(|v| v.as_str())
.unwrap_or("normal")
.to_string(),
})
}
pub fn parse_search(val: &Value, count: usize) -> Vec<SearchNote> {
let notes = match val.get("notes").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return vec![],
};
notes
.iter()
.take(count)
.filter_map(|item| {
let id = item.get("id")?.as_str()?.to_string();
Some(SearchNote {
id,
title: item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
author: item
.get("author")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
likes: item.get("likes").and_then(|v| v.as_u64()).unwrap_or(0),
published_at: item
.get("publishedAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
url: item
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
cover_image: item
.get("coverImage")
.and_then(|v| v.as_str())
.map(String::from),
})
})
.collect()
}
pub fn parse_comments(val: &Value, count: usize) -> Vec<Comment> {
let comments = match val.get("comments").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return vec![],
};
comments
.iter()
.take(count)
.filter_map(|item| {
let text = item.get("text")?.as_str()?.to_string();
if text.is_empty() {
return None;
}
Some(parse_single_comment(item))
})
.collect()
}
fn parse_single_comment(item: &Value) -> Comment {
Comment {
author: item
.get("author")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
text: item
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
likes: item.get("likes").and_then(|v| v.as_u64()).unwrap_or(0),
time: item
.get("time")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
replies: item
.get("replies")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(parse_single_comment).collect())
.unwrap_or_default(),
}
}
pub fn merge_replies(comments: &mut [Comment], replies_val: &Value) {
let parent_replies = match replies_val.get("parentReplies").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return,
};
for (i, comment) in comments.iter_mut().enumerate() {
if let Some(replies) = parent_replies.get(i).and_then(|v| v.as_array()) {
comment.replies = replies.iter().map(parse_single_comment).collect();
}
}
}
pub fn parse_user_notes(val: &Value, count: usize) -> Vec<UserNote> {
let notes = match val.get("notes").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return vec![],
};
notes
.iter()
.take(count)
.filter_map(|item| {
let id = item.get("id")?.as_str()?.to_string();
Some(UserNote {
id,
title: item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
likes: item.get("likes").and_then(|v| v.as_u64()).unwrap_or(0),
note_type: item
.get("noteType")
.and_then(|v| v.as_str())
.unwrap_or("normal")
.to_string(),
cover_image: item
.get("coverImage")
.and_then(|v| v.as_str())
.map(String::from),
published_at: item
.get("publishedAt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
})
.collect()
}
pub fn parse_media(val: &Value) -> Vec<MediaItem> {
let media = match val.get("media").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return vec![],
};
media
.iter()
.filter_map(|item| {
let url = item.get("url")?.as_str()?.to_string();
let media_type = item
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("image")
.to_string();
Some(MediaItem { media_type, url })
})
.collect()
}
pub fn parse_feed(val: &Value, count: usize) -> Vec<FeedItem> {
let items = match val.as_array() {
Some(arr) => arr,
None => return vec![],
};
items
.iter()
.take(count)
.filter_map(|item| {
let id = item.get("id")?.as_str()?.to_string();
Some(FeedItem {
id,
title: item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
author: item
.get("author")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
likes: item.get("likes").and_then(|v| v.as_u64()).unwrap_or(0),
note_type: item
.get("noteType")
.and_then(|v| v.as_str())
.unwrap_or("normal")
.to_string(),
url: item
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
})
.collect()
}
pub fn parse_notifications(val: &Value, count: usize) -> Vec<Notification> {
let items = match val.as_array() {
Some(arr) => arr,
None => return vec![],
};
items
.iter()
.take(count)
.map(|item| Notification {
notification_type: item
.get("notificationType")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
user: item
.get("user")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
content: item
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
time: item
.get("time")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
target_note_id: item
.get("targetNoteId")
.and_then(|v| v.as_str())
.map(String::from),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_check_page_status_ok() {
let val = json!({ "loginWall": false, "notFound": false, "note": {} });
assert!(check_page_status(&val).is_ok());
}
#[test]
fn test_check_page_status_login_wall() {
let val = json!({ "loginWall": true, "notFound": false });
let err = check_page_status(&val).unwrap_err();
assert!(matches!(err, TailFinError::AuthRequired));
}
#[test]
fn test_check_page_status_not_found() {
let val = json!({ "loginWall": false, "notFound": true });
let err = check_page_status(&val).unwrap_err();
match err {
TailFinError::Api(msg) => assert!(msg.contains("not found")),
other => panic!("expected Api error, got {:?}", other),
}
}
#[test]
fn test_parse_note_basic() {
let val = json!({
"loginWall": false,
"notFound": false,
"note": {
"id": "6789abcdef0123456789abcd",
"title": "Test Note",
"content": "Hello world",
"author": "Alice",
"authorId": "user123",
"likes": 42,
"collects": 10,
"comments": 5,
"shares": 2,
"tags": ["travel", "food"],
"images": ["https://example.com/img1.jpg"],
"publishedAt": "2024-01-20",
"noteType": "normal"
}
});
let note = parse_note(&val).expect("parse_note should succeed");
assert_eq!(note.id, "6789abcdef0123456789abcd");
assert_eq!(note.title, "Test Note");
assert_eq!(note.content, "Hello world");
assert_eq!(note.author, "Alice");
assert_eq!(note.author_id, "user123");
assert_eq!(note.likes, 42);
assert_eq!(note.collects, 10);
assert_eq!(note.comments, 5);
assert_eq!(note.shares, 2);
assert_eq!(note.tags, vec!["travel", "food"]);
assert_eq!(note.images, vec!["https://example.com/img1.jpg"]);
assert!(note.video.is_none());
assert_eq!(note.published_at, "2024-01-20");
assert_eq!(note.note_type, "normal");
}
#[test]
fn test_parse_note_with_video() {
let val = json!({
"loginWall": false,
"notFound": false,
"note": {
"id": "6789abcdef0123456789abcd",
"title": "Video Note",
"content": "Watch this",
"author": "Bob",
"authorId": "user456",
"likes": 100,
"collects": 50,
"comments": 20,
"shares": 10,
"tags": [],
"images": [],
"video": "https://sns-video-bd.xhscdn.com/video.mp4",
"publishedAt": "2024-03-15",
"noteType": "video"
}
});
let note = parse_note(&val).expect("parse_note should succeed");
assert_eq!(note.note_type, "video");
assert_eq!(
note.video,
Some("https://sns-video-bd.xhscdn.com/video.mp4".to_string())
);
assert_eq!(note.tags.len(), 0);
assert_eq!(note.images.len(), 0);
}
#[test]
fn test_parse_note_missing_data_uses_defaults() {
let val = json!({
"note": {}
});
let note = parse_note(&val).expect("parse_note should succeed with empty note");
assert_eq!(note.id, "");
assert_eq!(note.title, "");
assert_eq!(note.content, "");
assert_eq!(note.author, "");
assert_eq!(note.author_id, "");
assert_eq!(note.likes, 0);
assert_eq!(note.collects, 0);
assert_eq!(note.comments, 0);
assert_eq!(note.shares, 0);
assert!(note.tags.is_empty());
assert!(note.images.is_empty());
assert!(note.video.is_none());
assert_eq!(note.published_at, "");
assert_eq!(note.note_type, "normal");
}
#[test]
fn test_parse_note_missing_note_key_returns_error() {
let val = json!({
"loginWall": false,
"notFound": false,
"error": "noteDetailMap not found"
});
let err = parse_note(&val).unwrap_err();
match err {
TailFinError::Parse(msg) => {
assert!(msg.contains("failed to extract note data"));
assert!(msg.contains("noteDetailMap not found"));
}
other => panic!("expected Parse error, got {:?}", other),
}
}
#[test]
fn test_parse_note_missing_note_key_unknown_error() {
let val = json!({ "loginWall": false });
let err = parse_note(&val).unwrap_err();
match err {
TailFinError::Parse(msg) => assert!(msg.contains("unknown")),
other => panic!("expected Parse error, got {:?}", other),
}
}
#[test]
fn test_parse_search_two_results() {
let val = json!({
"loginWall": false,
"notFound": false,
"notes": [
{
"id": "abc123",
"title": "First Note",
"author": "Alice",
"likes": 100,
"publishedAt": "2024-01-15",
"url": "https://www.xiaohongshu.com/explore/abc123",
"coverImage": "https://example.com/cover1.jpg"
},
{
"id": "def456",
"title": "Second Note",
"author": "Bob",
"likes": 50,
"publishedAt": "2024-02-20",
"url": "https://www.xiaohongshu.com/explore/def456"
}
]
});
let results = parse_search(&val, 10);
assert_eq!(results.len(), 2);
let first = &results[0];
assert_eq!(first.id, "abc123");
assert_eq!(first.title, "First Note");
assert_eq!(first.author, "Alice");
assert_eq!(first.likes, 100);
assert_eq!(first.published_at, "2024-01-15");
assert_eq!(first.url, "https://www.xiaohongshu.com/explore/abc123");
assert_eq!(
first.cover_image,
Some("https://example.com/cover1.jpg".to_string())
);
let second = &results[1];
assert_eq!(second.id, "def456");
assert_eq!(second.title, "Second Note");
assert_eq!(second.author, "Bob");
assert_eq!(second.likes, 50);
assert_eq!(second.published_at, "2024-02-20");
assert_eq!(second.url, "https://www.xiaohongshu.com/explore/def456");
assert!(second.cover_image.is_none());
}
#[test]
fn test_parse_search_respects_count_limit() {
let val = json!({
"notes": [
{ "id": "id1", "title": "A", "author": "X", "likes": 1, "publishedAt": "", "url": "" },
{ "id": "id2", "title": "B", "author": "Y", "likes": 2, "publishedAt": "", "url": "" },
{ "id": "id3", "title": "C", "author": "Z", "likes": 3, "publishedAt": "", "url": "" }
]
});
let results = parse_search(&val, 2);
assert_eq!(results.len(), 2);
assert_eq!(results[0].id, "id1");
assert_eq!(results[1].id, "id2");
}
#[test]
fn test_parse_search_empty_when_no_notes_key() {
let val = json!({ "loginWall": false });
let results = parse_search(&val, 10);
assert!(results.is_empty());
}
#[test]
fn test_parse_comments() {
let val = json!({
"loginWall": false,
"notFound": false,
"comments": [
{
"author": "Alice",
"text": "Great post!",
"likes": 12,
"time": "2024-03-01",
"replies": []
},
{
"author": "Bob",
"text": "Agreed!",
"likes": 5,
"time": "2024-03-02",
"replies": []
}
]
});
let comments = parse_comments(&val, 10);
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].author, "Alice");
assert_eq!(comments[0].text, "Great post!");
assert_eq!(comments[0].likes, 12);
assert_eq!(comments[0].time, "2024-03-01");
assert!(comments[0].replies.is_empty());
assert_eq!(comments[1].author, "Bob");
assert_eq!(comments[1].text, "Agreed!");
assert_eq!(comments[1].likes, 5);
assert_eq!(comments[1].time, "2024-03-02");
}
#[test]
fn test_parse_comments_respects_count_limit() {
let val = json!({
"comments": [
{ "author": "A", "text": "first", "likes": 1, "time": "", "replies": [] },
{ "author": "B", "text": "second", "likes": 2, "time": "", "replies": [] },
{ "author": "C", "text": "third", "likes": 3, "time": "", "replies": [] },
]
});
let comments = parse_comments(&val, 2);
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].author, "A");
assert_eq!(comments[1].author, "B");
}
#[test]
fn test_parse_user_notes_two_notes() {
let val = json!({
"loginWall": false,
"notFound": false,
"notes": [
{
"id": "aabbcc001122334455667788",
"title": "My Travel Post",
"likes": 250,
"noteType": "normal",
"coverImage": "https://ci.xiaohongshu.com/cover1.jpg",
"publishedAt": "2024-06-01"
},
{
"id": "bbccdd112233445566778899",
"title": "Video Diary",
"likes": 1000,
"noteType": "video",
"publishedAt": "2024-07-15"
}
]
});
let notes = parse_user_notes(&val, 10);
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].id, "aabbcc001122334455667788");
assert_eq!(notes[0].title, "My Travel Post");
assert_eq!(notes[0].likes, 250);
assert_eq!(notes[0].note_type, "normal");
assert_eq!(
notes[0].cover_image,
Some("https://ci.xiaohongshu.com/cover1.jpg".to_string())
);
assert_eq!(notes[0].published_at, "2024-06-01");
assert_eq!(notes[1].id, "bbccdd112233445566778899");
assert_eq!(notes[1].title, "Video Diary");
assert_eq!(notes[1].likes, 1000);
assert_eq!(notes[1].note_type, "video");
assert!(notes[1].cover_image.is_none());
assert_eq!(notes[1].published_at, "2024-07-15");
}
#[test]
fn test_parse_user_notes_respects_count() {
let val = json!({
"notes": [
{ "id": "id1", "title": "A", "likes": 1, "noteType": "normal", "publishedAt": "" },
{ "id": "id2", "title": "B", "likes": 2, "noteType": "normal", "publishedAt": "" },
{ "id": "id3", "title": "C", "likes": 3, "noteType": "normal", "publishedAt": "" },
]
});
let notes = parse_user_notes(&val, 2);
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].id, "id1");
assert_eq!(notes[1].id, "id2");
}
#[test]
fn test_parse_user_notes_empty_when_no_notes_key() {
let val = json!({ "loginWall": false });
let notes = parse_user_notes(&val, 10);
assert!(notes.is_empty());
}
#[test]
fn test_parse_media_three_items_including_video() {
let val = json!({
"loginWall": false,
"notFound": false,
"media": [
{ "type": "image", "url": "https://ci.xiaohongshu.com/img1.jpg" },
{ "type": "image", "url": "https://ci.xiaohongshu.com/img2.jpg" },
{ "type": "video", "url": "https://sns-video-bd.xhscdn.com/clip.mp4" }
]
});
let items = parse_media(&val);
assert_eq!(items.len(), 3);
assert_eq!(items[0].media_type, "image");
assert_eq!(items[0].url, "https://ci.xiaohongshu.com/img1.jpg");
assert_eq!(items[1].media_type, "image");
assert_eq!(items[1].url, "https://ci.xiaohongshu.com/img2.jpg");
assert_eq!(items[2].media_type, "video");
assert_eq!(items[2].url, "https://sns-video-bd.xhscdn.com/clip.mp4");
}
#[test]
fn test_parse_media_empty() {
let val = json!({ "loginWall": false, "media": [] });
let items = parse_media(&val);
assert!(items.is_empty());
}
#[test]
fn test_parse_media_no_media_key() {
let val = json!({ "loginWall": false });
let items = parse_media(&val);
assert!(items.is_empty());
}
#[test]
fn test_parse_feed_two_items() {
let val = json!([
{
"id": "feedid001",
"title": "Summer Vibes",
"author": "Alice",
"likes": 320,
"noteType": "normal",
"url": "https://www.xiaohongshu.com/explore/feedid001"
},
{
"id": "feedid002",
"title": "Travel Vlog",
"author": "Bob",
"likes": 850,
"noteType": "video",
"url": "https://www.xiaohongshu.com/explore/feedid002"
}
]);
let items = parse_feed(&val, 10);
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "feedid001");
assert_eq!(items[0].title, "Summer Vibes");
assert_eq!(items[0].author, "Alice");
assert_eq!(items[0].likes, 320);
assert_eq!(items[0].note_type, "normal");
assert_eq!(
items[0].url,
"https://www.xiaohongshu.com/explore/feedid001"
);
assert_eq!(items[1].id, "feedid002");
assert_eq!(items[1].title, "Travel Vlog");
assert_eq!(items[1].author, "Bob");
assert_eq!(items[1].likes, 850);
assert_eq!(items[1].note_type, "video");
assert_eq!(
items[1].url,
"https://www.xiaohongshu.com/explore/feedid002"
);
}
#[test]
fn test_parse_feed_respects_count() {
let val = json!([
{ "id": "f1", "title": "A", "author": "X", "likes": 1, "noteType": "normal", "url": "" },
{ "id": "f2", "title": "B", "author": "Y", "likes": 2, "noteType": "normal", "url": "" },
{ "id": "f3", "title": "C", "author": "Z", "likes": 3, "noteType": "normal", "url": "" },
]);
let items = parse_feed(&val, 2);
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "f1");
assert_eq!(items[1].id, "f2");
}
#[test]
fn test_parse_feed_empty_when_not_array() {
let val = json!({ "notes": [] });
let items = parse_feed(&val, 10);
assert!(items.is_empty());
}
#[test]
fn test_parse_notifications_two_items() {
let val = json!([
{
"notificationType": "like",
"user": "Alice",
"content": "liked your note",
"time": "2024-05-01",
"targetNoteId": "note123"
},
{
"notificationType": "follow",
"user": "Bob",
"content": "started following you",
"time": "2024-05-02"
}
]);
let notifs = parse_notifications(&val, 10);
assert_eq!(notifs.len(), 2);
assert_eq!(notifs[0].notification_type, "like");
assert_eq!(notifs[0].user, "Alice");
assert_eq!(notifs[0].content, "liked your note");
assert_eq!(notifs[0].time, "2024-05-01");
assert_eq!(notifs[0].target_note_id, Some("note123".to_string()));
assert_eq!(notifs[1].notification_type, "follow");
assert_eq!(notifs[1].user, "Bob");
assert_eq!(notifs[1].content, "started following you");
assert_eq!(notifs[1].time, "2024-05-02");
assert!(notifs[1].target_note_id.is_none());
}
#[test]
fn test_parse_notifications_respects_count() {
let val = json!([
{ "notificationType": "like", "user": "A", "content": "1", "time": "" },
{ "notificationType": "comment", "user": "B", "content": "2", "time": "" },
{ "notificationType": "follow", "user": "C", "content": "3", "time": "" },
]);
let notifs = parse_notifications(&val, 2);
assert_eq!(notifs.len(), 2);
assert_eq!(notifs[0].notification_type, "like");
assert_eq!(notifs[1].notification_type, "comment");
}
#[test]
fn test_parse_notifications_empty_when_not_array() {
let val = json!({ "data": [] });
let notifs = parse_notifications(&val, 10);
assert!(notifs.is_empty());
}
#[test]
fn test_merge_replies() {
let val = json!({
"comments": [
{ "author": "Alice", "text": "Hello", "likes": 0, "time": "", "replies": [] },
{ "author": "Bob", "text": "World", "likes": 0, "time": "", "replies": [] },
]
});
let mut comments = parse_comments(&val, 10);
assert_eq!(comments.len(), 2);
let replies_val = json!({
"parentReplies": [
[
{ "author": "Carol", "text": "Reply to Alice", "likes": 3, "time": "2024-04-01", "replies": [] }
],
[]
]
});
merge_replies(&mut comments, &replies_val);
assert_eq!(comments[0].replies.len(), 1);
assert_eq!(comments[0].replies[0].author, "Carol");
assert_eq!(comments[0].replies[0].text, "Reply to Alice");
assert_eq!(comments[0].replies[0].likes, 3);
assert_eq!(comments[1].replies.len(), 0);
}
}