tail-fin-twitter 0.5.1

Twitter/X adapter for tail-fin: timeline, search, profile, bookmarks, likes, thread, post, like, follow, block, bookmark, reply, trending, lists, article, download, notifications
Documentation
mod article;
mod notifications;
mod timeline;
mod users;

use std::collections::HashSet;

use serde_json::Value;

use crate::types::Tweet;

// Re-export all public functions so the external API is unchanged.
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;

/// Parse a single tweet from a GraphQL tweet_results.result object.
pub fn parse_tweet(result: &Value) -> Option<Tweet> {
    // Handle "tweet" wrapper (some results wrap the actual 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())
        // Also check note_tweet for long-form content
        .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,
    })
}

/// Generic parser for timeline-style instruction arrays.
pub fn parse_timeline_entries(instructions: Option<&Vec<Value>>) -> Vec<Tweet> {
    let (tweets, _) = parse_timeline_entries_with_cursor(instructions);
    tweets
}

/// Generic parser for timeline-style instruction arrays, also returning the bottom cursor.
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;
            }

            // Skip other cursor entries
            if entry_id.starts_with("cursor-") {
                continue;
            }

            // Direct tweet
            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);
                    }
                }
            }

            // Nested items (conversation modules, thread items)
            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());
    }
}