mod article;
mod notifications;
mod timeline;
mod users;
use std::collections::HashSet;
use serde_json::Value;
use crate::types::Tweet;
pub use article::parse_article_response;
pub use notifications::parse_notifications_response;
pub use timeline::{
parse_bookmarks_response, parse_likes_response, parse_search_response, parse_thread_response,
parse_timeline_response,
};
pub use users::parse_user_list_response;
pub fn parse_tweet(result: &Value) -> Option<Tweet> {
let tweet_data = if result.get("tweet").is_some() {
result.get("tweet")?
} else {
result
};
let rest_id = tweet_data
.get("rest_id")
.and_then(|v| v.as_str())?
.to_string();
let legacy = tweet_data.get("legacy")?;
let text = legacy
.get("full_text")
.and_then(|v| v.as_str())
.or_else(|| {
tweet_data
.pointer("/note_tweet/note_tweet_results/result/text")
.and_then(|v| v.as_str())
})
.unwrap_or("")
.to_string();
let user_result = tweet_data.pointer("/core/user_results/result");
let (author, author_name) = match user_result {
Some(ur) => {
let screen_name = ur
.pointer("/core/screen_name")
.or_else(|| ur.pointer("/legacy/screen_name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = ur
.pointer("/core/name")
.or_else(|| ur.pointer("/legacy/name"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
(screen_name, name)
}
None => (String::new(), String::new()),
};
let likes = legacy
.get("favorite_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let retweets = legacy
.get("retweet_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let replies = legacy
.get("reply_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let views = tweet_data
.pointer("/views/count")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<u64>().ok());
let created_at = legacy
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(Tweet {
id: rest_id,
text,
author,
author_name,
likes,
retweets,
replies,
views,
created_at,
})
}
pub fn parse_timeline_entries(instructions: Option<&Vec<Value>>) -> Vec<Tweet> {
let (tweets, _) = parse_timeline_entries_with_cursor(instructions);
tweets
}
pub fn parse_timeline_entries_with_cursor(
instructions: Option<&Vec<Value>>,
) -> (Vec<Tweet>, Option<String>) {
let instructions = match instructions {
Some(i) => i,
None => return (vec![], None),
};
let mut tweets = Vec::new();
let mut seen = HashSet::new();
let mut next_cursor = None;
for instruction in instructions {
let entries = match instruction.get("entries").and_then(|e| e.as_array()) {
Some(e) => e,
None => continue,
};
for entry in entries {
let entry_id = entry.get("entryId").and_then(|v| v.as_str()).unwrap_or("");
if entry_id.starts_with("cursor-bottom-") {
if let Some(cursor_value) = entry.pointer("/content/value").and_then(|v| v.as_str())
{
next_cursor = Some(cursor_value.to_string());
}
continue;
}
if entry_id.starts_with("cursor-") {
continue;
}
if let Some(result) = entry.pointer("/content/itemContent/tweet_results/result") {
if let Some(tweet) = parse_tweet(result) {
if seen.insert(tweet.id.clone()) {
tweets.push(tweet);
}
}
}
if let Some(items) = entry.pointer("/content/items").and_then(|v| v.as_array()) {
for item in items {
if let Some(result) = item.pointer("/item/itemContent/tweet_results/result") {
if let Some(tweet) = parse_tweet(result) {
if seen.insert(tweet.id.clone()) {
tweets.push(tweet);
}
}
}
}
}
}
}
(tweets, next_cursor)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tweet_full() {
let result = serde_json::json!({
"rest_id": "42",
"legacy": {
"full_text": "A tweet",
"favorite_count": 10,
"retweet_count": 3,
"reply_count": 7,
"created_at": "Mon Jan 01 00:00:00 +0000 2024"
},
"core": { "user_results": { "result": {
"legacy": { "screen_name": "bob", "name": "Bob" }
}}},
"views": { "count": "500" }
});
let tweet = parse_tweet(&result).unwrap();
assert_eq!(tweet.id, "42");
assert_eq!(tweet.text, "A tweet");
assert_eq!(tweet.author, "bob");
assert_eq!(tweet.author_name, "Bob");
assert_eq!(tweet.likes, 10);
assert_eq!(tweet.retweets, 3);
assert_eq!(tweet.replies, 7);
assert_eq!(tweet.views, Some(500));
}
#[test]
fn test_parse_tweet_with_tweet_wrapper() {
let result = serde_json::json!({
"tweet": {
"rest_id": "77",
"legacy": {
"full_text": "Wrapped",
"favorite_count": 0,
"retweet_count": 0,
"reply_count": 0,
"created_at": ""
},
"core": { "user_results": { "result": {
"legacy": { "screen_name": "wrap", "name": "Wrap" }
}}}
}
});
let tweet = parse_tweet(&result).unwrap();
assert_eq!(tweet.id, "77");
assert_eq!(tweet.text, "Wrapped");
assert_eq!(tweet.author, "wrap");
}
#[test]
fn test_parse_tweet_missing_rest_id() {
let result = serde_json::json!({
"legacy": { "full_text": "no id" }
});
assert!(parse_tweet(&result).is_none());
}
#[test]
fn test_parse_timeline_entries_dedup() {
let instructions = vec![serde_json::json!({
"entries": [
{
"entryId": "tweet-1",
"content": {
"itemContent": {
"tweet_results": {
"result": {
"rest_id": "1",
"legacy": {
"full_text": "first",
"favorite_count": 0, "retweet_count": 0,
"reply_count": 0, "created_at": ""
},
"core": { "user_results": { "result": {
"legacy": { "screen_name": "u", "name": "U" }
}}}
}
}
}
}
},
{
"entryId": "tweet-1-dup",
"content": {
"itemContent": {
"tweet_results": {
"result": {
"rest_id": "1",
"legacy": {
"full_text": "first again",
"favorite_count": 0, "retweet_count": 0,
"reply_count": 0, "created_at": ""
},
"core": { "user_results": { "result": {
"legacy": { "screen_name": "u", "name": "U" }
}}}
}
}
}
}
},
{
"entryId": "tweet-2",
"content": {
"itemContent": {
"tweet_results": {
"result": {
"rest_id": "2",
"legacy": {
"full_text": "second",
"favorite_count": 0, "retweet_count": 0,
"reply_count": 0, "created_at": ""
},
"core": { "user_results": { "result": {
"legacy": { "screen_name": "u", "name": "U" }
}}}
}
}
}
}
}
]
})];
let tweets = parse_timeline_entries(Some(&instructions));
assert_eq!(tweets.len(), 2);
assert_eq!(tweets[0].id, "1");
assert_eq!(tweets[1].id, "2");
}
#[test]
fn test_parse_timeline_entries_none() {
let tweets = parse_timeline_entries(None);
assert!(tweets.is_empty());
}
}