Add support for parsing Tweet Detail in APIv2, including its unusual cursor format and conversation threads
This commit is contained in:
parent
693831704d
commit
21581b325a
@ -334,6 +334,10 @@ func (api_v2_tweet APIV2Tweet) ToTweetTrove() TweetTrove {
|
|||||||
type ItemContent struct {
|
type ItemContent struct {
|
||||||
ItemType string `json:"itemType"`
|
ItemType string `json:"itemType"`
|
||||||
TweetResults APIV2Result `json:"tweet_results"`
|
TweetResults APIV2Result `json:"tweet_results"`
|
||||||
|
|
||||||
|
// Cursors (conversation view format)
|
||||||
|
CursorType string `json:"cursorType"`
|
||||||
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wraps InnerAPIV2Entry to implement `json.Unmarshal`. Does the normal unmarshal but also saves the original JSON.
|
// Wraps InnerAPIV2Entry to implement `json.Unmarshal`. Does the normal unmarshal but also saves the original JSON.
|
||||||
@ -355,7 +359,7 @@ type InnerAPIV2Entry struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursors
|
// Cursors (user feed format)
|
||||||
EntryType string `json:"entryType"`
|
EntryType string `json:"entryType"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
CursorType string `json:"cursorType"`
|
CursorType string `json:"cursorType"`
|
||||||
@ -378,22 +382,29 @@ func (e APIV2Entry) ToTweetTrove(ignore_null_entries bool) TweetTrove {
|
|||||||
panic(obj)
|
panic(obj)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if e.Content.EntryType == "TimelineTimelineCursor" {
|
if e.Content.EntryType == "TimelineTimelineCursor" || e.Content.ItemContent.ItemType == "TimelineTimelineCursor" {
|
||||||
// Ignore cursor entries
|
// Ignore cursor entries.
|
||||||
|
// - e.Content.EntryType -> User Feed itself
|
||||||
|
// - e.Content.ItemContent.ItemType -> conversation thread in a user feed
|
||||||
return NewTweetTrove()
|
return NewTweetTrove()
|
||||||
} else if e.Content.EntryType == "TimelineTimelineModule" {
|
} else if e.Content.EntryType == "TimelineTimelineModule" {
|
||||||
ret := NewTweetTrove()
|
ret := NewTweetTrove()
|
||||||
|
|
||||||
switch strings.Split(e.EntryID, "-")[0] {
|
switch strings.Split(e.EntryID, "-")[0] {
|
||||||
case "homeConversation":
|
case "homeConversation", "conversationthread":
|
||||||
// Process it
|
// Process it.
|
||||||
|
// - "homeConversation": conversation thread on a user feed
|
||||||
|
// - "conversationthread": conversation thread in the replies under a TweetDetail view
|
||||||
for _, item := range e.Content.Items {
|
for _, item := range e.Content.Items {
|
||||||
|
if item.Item.ItemContent.ItemType == "TimelineTimelineCursor" {
|
||||||
|
// "Show More" replies button in a thread on Tweet Detail page
|
||||||
|
continue
|
||||||
|
}
|
||||||
ret.MergeWith(item.Item.ItemContent.TweetResults.ToTweetTrove(ignore_null_entries))
|
ret.MergeWith(item.Item.ItemContent.TweetResults.ToTweetTrove(ignore_null_entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
case "whoToFollow":
|
case "whoToFollow", "TopicsModule", "tweetdetailrelatedtweets":
|
||||||
case "TopicsModule":
|
// Ignore "Who to follow", "Topics" and "Related Tweets" modules.
|
||||||
// Ignore "Who to follow" and "Topics" modules.
|
|
||||||
// TODO: maybe we can capture these eventually
|
// TODO: maybe we can capture these eventually
|
||||||
log.Debug(fmt.Sprintf("Skipping %s entry", e.EntryID))
|
log.Debug(fmt.Sprintf("Skipping %s entry", e.EntryID))
|
||||||
|
|
||||||
@ -424,6 +435,9 @@ type APIV2Response struct {
|
|||||||
} `json:"timeline"`
|
} `json:"timeline"`
|
||||||
} `json:"result"`
|
} `json:"result"`
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
|
ThreadedConversationWithInjectionsV2 struct {
|
||||||
|
Instructions []APIV2Instruction `json:"instructions"`
|
||||||
|
} `json:"threaded_conversation_with_injections_v2"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -434,17 +448,28 @@ func (api_response APIV2Response) GetMainInstruction() *APIV2Instruction {
|
|||||||
return &instructions[i]
|
return &instructions[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
instructions = api_response.Data.ThreadedConversationWithInjectionsV2.Instructions
|
||||||
|
for i := range instructions {
|
||||||
|
if instructions[i].Type == "TimelineAddEntries" {
|
||||||
|
return &instructions[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
panic("No 'TimelineAddEntries' found")
|
panic("No 'TimelineAddEntries' found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api_response APIV2Response) GetCursorBottom() string {
|
func (api_response APIV2Response) GetCursorBottom() string {
|
||||||
entries := api_response.GetMainInstruction().Entries
|
for _, entry := range api_response.GetMainInstruction().Entries {
|
||||||
last_entry := entries[len(entries)-1]
|
// For a user feed:
|
||||||
if last_entry.Content.CursorType != "Bottom" {
|
if entry.Content.CursorType == "Bottom" {
|
||||||
panic("No bottom cursor found")
|
return entry.Content.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
return last_entry.Content.Value
|
// For a Tweet Detail page:
|
||||||
|
if entry.Content.ItemContent.CursorType == "Bottom" {
|
||||||
|
return entry.Content.ItemContent.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -680,3 +680,25 @@ func TestEntryWithConversationThread(t *testing.T) {
|
|||||||
_, is_ok = trove.Tweets[1624990170670850053] // Tweet 3
|
_, is_ok = trove.Tweets[1624990170670850053] // Tweet 3
|
||||||
assert.True(is_ok)
|
assert.True(is_ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On a Tweet Detail page, there's a thread of replies, and then it says "Show more..." underneath
|
||||||
|
// to extend the conversation. This is different from the "Show more..." button to load more
|
||||||
|
// replies to the original tweet!
|
||||||
|
func TestConversationThreadEntryWithShowMoreButton(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
require := require.New(t)
|
||||||
|
data, err := os.ReadFile("test_responses/api_v2/conversation_thread_entry_with_show_more_button.json")
|
||||||
|
require.NoError(err)
|
||||||
|
var entry_result APIV2Entry
|
||||||
|
err = json.Unmarshal(data, &entry_result)
|
||||||
|
require.NoError(err)
|
||||||
|
|
||||||
|
trove := entry_result.ToTweetTrove(true)
|
||||||
|
|
||||||
|
assert.Len(trove.Tweets, 1)
|
||||||
|
t1, is_ok := trove.Tweets[1649803385485377536]
|
||||||
|
assert.True(is_ok)
|
||||||
|
assert.Equal(TweetID(1649600354747572225), t1.InReplyToID)
|
||||||
|
|
||||||
|
assert.Len(trove.Users, 1)
|
||||||
|
}
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
{"entryId":"conversationthread-1649803385485377536","sortIndex":"7573771682107203542","content":{"entryType":"TimelineTimelineModule","__typename":"TimelineTimelineModule","items":[{"entryId":"conversationthread-1649803385485377536-tweet-1649803385485377536","item":{"itemContent":{"itemType":"TimelineTweet","__typename":"TimelineTweet","tweet_results":{"result":{"__typename":"Tweet","rest_id":"1649803385485377536","has_birdwatch_notes":false,"core":{"user_results":{"result":{"__typename":"User","id":"VXNlcjoxNDAzMTAzMg==","rest_id":"14031032","affiliates_highlighted_label":{},"is_blue_verified":true,"profile_image_shape":"Circle","legacy":{"created_at":"Tue Feb 26 22:01:28 +0000 2008","default_profile":false,"default_profile_image":false,"description":"VP marketing @AdQuick, out of home advertising made simple & measurable. Prev work: Google, Invitae, Marketo, etc","entities":{"description":{"urls":[]},"url":{"urls":[{"display_url":"adamsinger.substack.com/welcome","expanded_url":"https://adamsinger.substack.com/welcome","url":"https://t.co/6kI9bzQ2eV","indices":[0,23]}]}},"fast_followers_count":0,"favourites_count":355788,"followers_count":81891,"friends_count":4200,"has_custom_timelines":true,"is_translator":false,"listed_count":3083,"location":"Austin, TX","media_count":31104,"name":"Adam Singer","normal_followers_count":81891,"pinned_tweet_ids_str":[],"possibly_sensitive":false,"profile_banner_url":"https://pbs.twimg.com/profile_banners/14031032/1663584125","profile_image_url_https":"https://pbs.twimg.com/profile_images/1526507327574220804/vDv7S4U7_normal.jpg","profile_interstitial_type":"","screen_name":"AdamSinger","statuses_count":313313,"translator_type":"none","url":"https://t.co/6kI9bzQ2eV","verified":false,"withheld_in_countries":[]}}}},"edit_control":{"edit_tweet_ids":["1649803385485377536"],"editable_until_msecs":"1682180553000","is_edit_eligible":false,"edits_remaining":"5"},"is_translatable":false,"views":{"count":"1755","state":"EnabledWithCount"},"source":"<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>","legacy":{"bookmark_count":0,"bookmarked":false,"created_at":"Sat Apr 22 15:52:33 +0000 2023","conversation_id_str":"1649600354747572225","display_text_range":[13,143],"entities":{"user_mentions":[{"id_str":"886358633646350340","name":"LindyMan","screen_name":"PaulSkallas","indices":[0,12]}],"urls":[],"hashtags":[],"symbols":[]},"favorite_count":16,"favorited":false,"full_text":"@PaulSkallas Clickabait = fast/cheap attention = forgotten just as quickly. What do you think happens to all the pop music of the same variety?","in_reply_to_screen_name":"PaulSkallas","in_reply_to_status_id_str":"1649600354747572225","in_reply_to_user_id_str":"886358633646350340","is_quote_status":false,"lang":"en","quote_count":0,"reply_count":1,"retweet_count":0,"retweeted":false,"user_id_str":"14031032","id_str":"1649803385485377536"},"quick_promote_eligibility":{"eligibility":"IneligibleUserUnauthorized"}}},"tweetDisplayType":"Tweet"},"clientEventInfo":{"details":{"conversationDetails":{"conversationSection":"HighQuality"},"timelinesDetails":{"controllerData":"DAACDAAEDAABCgABFSACDDADgAUKAAIAAAAAGADACAAAAAA="}}}}},{"entryId":"conversationthread-1649803385485377536-cursor-showmore-6525681801715054743","item":{"itemContent":{"itemType":"TimelineTimelineCursor","__typename":"TimelineTimelineCursor","value":"PAAAAPAtPBwcFoCAvtGE3KPlLRUCAAAYJmNvbnZlcnNhdGlvbnRocmVhZC0xNjQ5ODAzMzg1NDg1Mzc3NTM2IgAA","cursorType":"ShowMore","displayTreatment":{"actionText":"Show replies"}},"clientEventInfo":{"details":{"conversationDetails":{"conversationSection":"HighQuality"}}}}}],"displayType":"VerticalConversation","clientEventInfo":{"details":{"conversationDetails":{"conversationSection":"HighQuality"}}}}}
|
Loading…
x
Reference in New Issue
Block a user