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
use serde_json::Value;

use tail_fin_common::TailFinError;

use crate::types::TimelineResponse;

use super::{parse_timeline_entries, parse_timeline_entries_with_cursor, parse_tweet};

/// Parse Twitter's GraphQL timeline response into structured data.
pub fn parse_timeline_response(data: &Value) -> Result<TimelineResponse, TailFinError> {
    let instructions = data
        .pointer("/data/home/home_timeline_urt/instructions")
        .or_else(|| data.pointer("/data/home/home_timeline/timeline/instructions"))
        .and_then(|v| v.as_array())
        .ok_or_else(|| TailFinError::Parse("missing timeline instructions".into()))?;

    let mut tweets = Vec::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("");

            // Extract pagination cursor
            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 non-tweet entries
            if !entry_id.starts_with("tweet-") {
                continue;
            }

            // Extract tweet data
            let tweet_result = entry
                .pointer("/content/itemContent/tweet_results/result")
                .or_else(|| {
                    entry.pointer("/content/items/0/item/itemContent/tweet_results/result")
                });

            if let Some(result) = tweet_result {
                // Skip promoted content
                if entry
                    .pointer("/content/itemContent/promotedMetadata")
                    .is_some()
                {
                    continue;
                }

                if let Some(tweet) = parse_tweet(result) {
                    tweets.push(tweet);
                }
            }
        }
    }

    Ok(TimelineResponse {
        tweets,
        next_cursor,
    })
}

/// Parse a SearchTimeline GraphQL response into tweets with cursor.
pub fn parse_search_response(data: &Value) -> TimelineResponse {
    let instructions = data
        .pointer("/data/search_by_raw_query/search_timeline/timeline/instructions")
        .and_then(|v| v.as_array());

    let instructions = match instructions {
        Some(i) => i,
        None => {
            return TimelineResponse {
                tweets: vec![],
                next_cursor: None,
            }
        }
    };

    let mut tweets = Vec::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("tweet-") {
                continue;
            }

            if let Some(result) = entry.pointer("/content/itemContent/tweet_results/result") {
                if let Some(tweet) = parse_tweet(result) {
                    tweets.push(tweet);
                }
            }
        }
    }

    TimelineResponse {
        tweets,
        next_cursor,
    }
}

pub fn parse_bookmarks_response(data: &Value) -> TimelineResponse {
    let instructions = data
        .pointer("/data/bookmark_timeline_v2/timeline/instructions")
        .or_else(|| data.pointer("/data/bookmark_timeline/timeline/instructions"))
        .and_then(|v| v.as_array());

    let (tweets, next_cursor) = parse_timeline_entries_with_cursor(instructions);
    TimelineResponse {
        tweets,
        next_cursor,
    }
}

pub fn parse_likes_response(data: &Value) -> TimelineResponse {
    let instructions = data
        .pointer("/data/user/result/timeline_v2/timeline/instructions")
        .or_else(|| data.pointer("/data/user/result/timeline/timeline/instructions"))
        .and_then(|v| v.as_array());

    let (tweets, next_cursor) = parse_timeline_entries_with_cursor(instructions);
    TimelineResponse {
        tweets,
        next_cursor,
    }
}

pub fn parse_thread_response(data: &Value) -> Vec<crate::types::Tweet> {
    let instructions = data
        .pointer("/data/threaded_conversation_with_injections_v2/instructions")
        .and_then(|v| v.as_array());

    parse_timeline_entries(instructions)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_timeline_response_basic() {
        let data = serde_json::json!({
            "data": {
                "home": {
                    "home_timeline_urt": {
                        "instructions": [{
                            "entries": [
                                {
                                    "entryId": "tweet-100",
                                    "content": {
                                        "itemContent": {
                                            "tweet_results": {
                                                "result": {
                                                    "rest_id": "100",
                                                    "legacy": {
                                                        "full_text": "Hello world",
                                                        "favorite_count": 5,
                                                        "retweet_count": 2,
                                                        "reply_count": 1,
                                                        "created_at": "Sun Apr 12 00:00:00 +0000 2026"
                                                    },
                                                    "core": { "user_results": { "result": {
                                                        "legacy": { "screen_name": "alice", "name": "Alice" }
                                                    }}},
                                                    "views": { "count": "999" }
                                                }
                                            }
                                        }
                                    }
                                },
                                {
                                    "entryId": "cursor-bottom-abc",
                                    "content": { "value": "scroll:abc" }
                                }
                            ]
                        }]
                    }
                }
            }
        });

        let resp = parse_timeline_response(&data).unwrap();
        assert_eq!(resp.tweets.len(), 1);
        assert_eq!(resp.tweets[0].id, "100");
        assert_eq!(resp.tweets[0].text, "Hello world");
        assert_eq!(resp.tweets[0].author, "alice");
        assert_eq!(resp.tweets[0].author_name, "Alice");
        assert_eq!(resp.tweets[0].likes, 5);
        assert_eq!(resp.tweets[0].retweets, 2);
        assert_eq!(resp.tweets[0].replies, 1);
        assert_eq!(resp.tweets[0].views, Some(999));
        assert_eq!(resp.next_cursor.as_deref(), Some("scroll:abc"));
    }

    #[test]
    fn test_parse_timeline_response_missing_instructions() {
        let data = serde_json::json!({ "data": { "home": {} } });
        let err = parse_timeline_response(&data);
        assert!(err.is_err());
    }

    #[test]
    fn test_parse_search_response_extracts_cursor() {
        let data = serde_json::json!({
            "data": {
                "search_by_raw_query": {
                    "search_timeline": {
                        "timeline": {
                            "instructions": [{
                                "entries": [
                                    {
                                        "entryId": "tweet-111",
                                        "content": {
                                            "itemContent": {
                                                "tweet_results": {
                                                    "result": {
                                                        "rest_id": "111",
                                                        "legacy": {
                                                            "full_text": "hello",
                                                            "favorite_count": 1,
                                                            "retweet_count": 0,
                                                            "reply_count": 0,
                                                            "created_at": "Mon Jan 01 00:00:00 +0000 2024"
                                                        },
                                                        "core": { "user_results": { "result": {
                                                            "legacy": { "screen_name": "user1", "name": "User 1" }
                                                        }}}
                                                    }
                                                }
                                            }
                                        }
                                    },
                                    {
                                        "entryId": "cursor-bottom-abc123",
                                        "content": { "value": "scroll:abc123xyz" }
                                    }
                                ]
                            }]
                        }
                    }
                }
            }
        });

        let resp = parse_search_response(&data);
        assert_eq!(resp.tweets.len(), 1);
        assert_eq!(resp.tweets[0].id, "111");
        assert_eq!(resp.next_cursor.as_deref(), Some("scroll:abc123xyz"));
    }

    #[test]
    fn test_parse_bookmarks_response_extracts_cursor() {
        let data = serde_json::json!({
            "data": {
                "bookmark_timeline_v2": {
                    "timeline": {
                        "instructions": [{
                            "entries": [
                                {
                                    "entryId": "tweet-222",
                                    "content": {
                                        "itemContent": {
                                            "tweet_results": {
                                                "result": {
                                                    "rest_id": "222",
                                                    "legacy": {
                                                        "full_text": "bookmarked",
                                                        "favorite_count": 5,
                                                        "retweet_count": 1,
                                                        "reply_count": 0,
                                                        "created_at": "Mon Jan 01 00:00:00 +0000 2024"
                                                    },
                                                    "core": { "user_results": { "result": {
                                                        "legacy": { "screen_name": "user2", "name": "User 2" }
                                                    }}}
                                                }
                                            }
                                        }
                                    }
                                },
                                {
                                    "entryId": "cursor-bottom-bkmk456",
                                    "content": { "value": "cursor:bkmk456" }
                                }
                            ]
                        }]
                    }
                }
            }
        });

        let resp = parse_bookmarks_response(&data);
        assert_eq!(resp.tweets.len(), 1);
        assert_eq!(resp.next_cursor.as_deref(), Some("cursor:bkmk456"));
    }

    #[test]
    fn test_parse_likes_response_extracts_cursor() {
        let data = serde_json::json!({
            "data": {
                "user": {
                    "result": {
                        "timeline_v2": {
                            "timeline": {
                                "instructions": [{
                                    "entries": [
                                        {
                                            "entryId": "tweet-333",
                                            "content": {
                                                "itemContent": {
                                                    "tweet_results": {
                                                        "result": {
                                                            "rest_id": "333",
                                                            "legacy": {
                                                                "full_text": "liked tweet",
                                                                "favorite_count": 10,
                                                                "retweet_count": 2,
                                                                "reply_count": 1,
                                                                "created_at": "Mon Jan 01 00:00:00 +0000 2024"
                                                            },
                                                            "core": { "user_results": { "result": {
                                                                "legacy": { "screen_name": "user3", "name": "User 3" }
                                                            }}}
                                                        }
                                                    }
                                                }
                                            }
                                        },
                                        {
                                            "entryId": "cursor-bottom-likes789",
                                            "content": { "value": "cursor:likes789" }
                                        }
                                    ]
                                }]
                            }
                        }
                    }
                }
            }
        });

        let resp = parse_likes_response(&data);
        assert_eq!(resp.tweets.len(), 1);
        assert_eq!(resp.next_cursor.as_deref(), Some("cursor:likes789"));
    }
}