Skip to content

Commit ae381f2

Browse files
authored
Merge pull request #10 from imperatrona/replies
add GetTweetReplies method
2 parents e053917 + 270637d commit ae381f2

File tree

6 files changed

+230
-7
lines changed

6 files changed

+230
-7
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v0.0.10
4+
5+
01.08.2024
6+
7+
- Added method `GetTweetReplies`
8+
39
## v0.0.9
410

511
24.07.2024

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ You can use this library to get tweets, profiles, and trends trivially.
2222
- [Log out](#log-out)
2323
- [Methods](#methods)
2424
- [Get tweet](#get-tweet)
25+
- [Get tweet replies](#get-tweet-replies)
2526
- [Get user tweets](#get-user-tweets)
2627
- [Get user medias](#get-user-medias)
2728
- [Get bookmarks](#get-bookmarks)
@@ -212,6 +213,44 @@ scraper.Logout()
212213
tweet, err := scraper.GetTweet("1328684389388185600")
213214
```
214215

216+
### Get tweet replies
217+
218+
150 requests / 15 minutes
219+
220+
Returns by ~5-10 tweets and multiple cursors – one for each thread.
221+
222+
```golang
223+
var cursor string
224+
tweets, cursors, err := scraper.GetTweetReplies("1328684389388185600", cursor)
225+
```
226+
227+
To get all replies and replies of replies for tweet you can iterate for all cursors. To get only direct replies check if `cursor.ThreadID` is equal your tweet id.
228+
229+
```golang
230+
tweets, cursors, err := testScraper.GetTweetReplies("1328684389388185600", "")
231+
if err != nil {
232+
panic(err)
233+
}
234+
235+
for {
236+
if len(cursors) > 0 {
237+
var cursor *twitterscraper.ThreadCursor
238+
cursor, cursors = cursors[0], cursors[1:]
239+
moreTweets, moreCursors, err := testScraper.GetTweetReplies(tweetId, cursor.Cursor)
240+
if err != nil {
241+
// you can check here if rate limited, await and repeat request
242+
panic(err)
243+
}
244+
tweets = append(tweets, moreTweets...)
245+
if len(moreCursors) > 0 {
246+
cursors = append(cursors, moreCursors...)
247+
}
248+
} else {
249+
break
250+
}
251+
}
252+
```
253+
215254
### Get user tweets
216255

217256
150 requests / 15 minutes

replies.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package twitterscraper
2+
3+
import "net/url"
4+
5+
type ThreadCursor struct {
6+
FocalTweetID string
7+
ThreadID string
8+
Cursor string
9+
CursorType string
10+
}
11+
12+
func (s *Scraper) GetTweetReplies(id string, cursor string) ([]*Tweet, []*ThreadCursor, error) {
13+
req, err := s.newRequest("GET", "https://twitter.com/i/api/graphql/ldqoq5MmFHN1FhMGvzC9Jg/TweetDetail")
14+
if err != nil {
15+
return nil, nil, err
16+
}
17+
18+
variables := map[string]interface{}{
19+
"focalTweetId": id,
20+
"referrer": "tweet",
21+
"with_rux_injections": false,
22+
"rankingMode": "Relevance",
23+
"includePromotedContent": true,
24+
"withCommunity": true,
25+
"withQuickPromoteEligibilityTweetFields": true,
26+
"withBirdwatchNotes": true,
27+
"withVoice": true,
28+
}
29+
30+
if cursor != "" {
31+
variables["cursor"] = cursor
32+
}
33+
34+
features := map[string]interface{}{
35+
"rweb_tipjar_consumption_enabled": true,
36+
"responsive_web_graphql_exclude_directive_enabled": true,
37+
"verified_phone_label_enabled": false,
38+
"creator_subscriptions_tweet_preview_api_enabled": true,
39+
"responsive_web_graphql_timeline_navigation_enabled": true,
40+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": false,
41+
"communities_web_enable_tweet_community_results_fetch": true,
42+
"c9s_tweet_anatomy_moderator_badge_enabled": true,
43+
"articles_preview_enabled": true,
44+
"tweetypie_unmention_optimization_enabled": true,
45+
"responsive_web_edit_tweet_api_enabled": true,
46+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
47+
"view_counts_everywhere_api_enabled": true,
48+
"longform_notetweets_consumption_enabled": true,
49+
"responsive_web_twitter_article_tweet_consumption_enabled": true,
50+
"tweet_awards_web_tipping_enabled": false,
51+
"creator_subscriptions_quote_tweet_preview_enabled": false,
52+
"freedom_of_speech_not_reach_fetch_enabled": true,
53+
"standardized_nudges_misinfo": true,
54+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true,
55+
"rweb_video_timestamps_enabled": true,
56+
"longform_notetweets_rich_text_read_enabled": true,
57+
"longform_notetweets_inline_media_enabled": true,
58+
"responsive_web_enhance_cards_enabled": false,
59+
}
60+
61+
fieldToggles := map[string]interface{}{
62+
"withArticleRichContentState": true,
63+
"withArticlePlainText": false,
64+
"withGrokAnalyze": false,
65+
"withDisallowedReplyControls": false,
66+
}
67+
68+
query := url.Values{}
69+
query.Set("variables", mapToJSONString(variables))
70+
query.Set("features", mapToJSONString(features))
71+
query.Set("fieldToggles", mapToJSONString(fieldToggles))
72+
req.URL.RawQuery = query.Encode()
73+
74+
var threads threadedConversation
75+
76+
err = s.RequestAPI(req, &threads)
77+
if err != nil {
78+
return nil, nil, err
79+
}
80+
81+
tweets, cursors := threads.parse(id)
82+
83+
return tweets, cursors, nil
84+
}

replies_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package twitterscraper_test
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestGetReplies(t *testing.T) {
8+
if skipAuthTest {
9+
t.Skip("Skipping test due to environment variable")
10+
}
11+
12+
tweetId := "1697304622749086011"
13+
14+
tweets, cursors, err := testScraper.GetTweetReplies(tweetId, "")
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
19+
if len(tweets) < 2 {
20+
t.Fatal("Less than 2 tweets returned")
21+
}
22+
23+
if len(cursors) < 1 {
24+
t.Fatal("No cursors returned")
25+
}
26+
}
27+

timeline_v2.go

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package twitterscraper
22

33
import (
44
"strconv"
5+
"strings"
56
)
67

78
type tweet struct {
@@ -74,12 +75,16 @@ func (result *userResult) parse() Profile {
7475
}
7576

7677
type item struct {
77-
Item struct {
78+
EntryID string `json:"entryId"`
79+
Item struct {
7880
ItemContent struct {
81+
ItemType string `json:"itemType"`
7982
TweetDisplayType string `json:"tweetDisplayType"`
8083
TweetResults struct {
8184
Result result `json:"result"`
8285
} `json:"tweet_results"`
86+
CursorType string `json:"cursorType"`
87+
Value string `json:"value"`
8388
} `json:"itemContent"`
8489
} `json:"item"`
8590
}
@@ -90,6 +95,7 @@ type entry struct {
9095
Value string `json:"value"`
9196
Items []item `json:"items"`
9297
ItemContent struct {
98+
ItemType string `json:"itemType"`
9399
TweetDisplayType string `json:"tweetDisplayType"`
94100
TweetResults struct {
95101
Result result `json:"result"`
@@ -98,6 +104,8 @@ type entry struct {
98104
UserResults struct {
99105
Result userResult `json:"result"`
100106
} `json:"user_results"`
107+
CursorType string `json:"cursorType"`
108+
Value string `json:"value"`
101109
} `json:"itemContent"`
102110
} `json:"content"`
103111
}
@@ -221,16 +229,18 @@ type threadedConversation struct {
221229
Data struct {
222230
ThreadedConversationWithInjectionsV2 struct {
223231
Instructions []struct {
224-
Type string `json:"type"`
225-
Entries []entry `json:"entries"`
226-
Entry entry `json:"entry"`
232+
Type string `json:"type"`
233+
Entry entry `json:"entry"`
234+
Entries []entry `json:"entries"`
235+
ModuleItems []item `json:"moduleItems"`
227236
} `json:"instructions"`
228237
} `json:"threaded_conversation_with_injections_v2"`
229238
} `json:"data"`
230239
}
231240

232-
func (conversation *threadedConversation) parse() []*Tweet {
241+
func (conversation *threadedConversation) parse(focalTweetID string) ([]*Tweet, []*ThreadCursor) {
233242
var tweets []*Tweet
243+
var cursors []*ThreadCursor
234244
for _, instruction := range conversation.Data.ThreadedConversationWithInjectionsV2.Instructions {
235245
for _, entry := range instruction.Entries {
236246
if entry.Content.ItemContent.TweetResults.Result.Typename == "Tweet" || entry.Content.ItemContent.TweetResults.Result.Typename == "TweetWithVisibilityResults" {
@@ -241,6 +251,16 @@ func (conversation *threadedConversation) parse() []*Tweet {
241251
tweets = append(tweets, tweet)
242252
}
243253
}
254+
255+
if entry.Content.ItemContent.CursorType != "" && entry.Content.ItemContent.Value != "" {
256+
cursors = append(cursors, &ThreadCursor{
257+
FocalTweetID: focalTweetID,
258+
ThreadID: focalTweetID,
259+
Cursor: entry.Content.ItemContent.Value,
260+
CursorType: entry.Content.ItemContent.CursorType,
261+
})
262+
}
263+
244264
for _, item := range entry.Content.Items {
245265
if item.Item.ItemContent.TweetResults.Result.Typename == "Tweet" || item.Item.ItemContent.TweetResults.Result.Typename == "TweetWithVisibilityResults" {
246266
if tweet := item.Item.ItemContent.TweetResults.Result.parse(); tweet != nil {
@@ -250,9 +270,56 @@ func (conversation *threadedConversation) parse() []*Tweet {
250270
tweets = append(tweets, tweet)
251271
}
252272
}
273+
274+
if item.Item.ItemContent.CursorType != "" && item.Item.ItemContent.Value != "" {
275+
threadID := ""
276+
277+
entryId := strings.Split(item.EntryID, "-")
278+
if len(entryId) > 1 && entryId[0] == "conversationthread" {
279+
if i, _ := strconv.Atoi(entryId[1]); i != 0 {
280+
threadID = entryId[1]
281+
}
282+
}
283+
284+
cursors = append(cursors, &ThreadCursor{
285+
FocalTweetID: focalTweetID,
286+
ThreadID: threadID,
287+
Cursor: item.Item.ItemContent.Value,
288+
CursorType: item.Item.ItemContent.CursorType,
289+
})
290+
}
291+
}
292+
}
293+
for _, item := range instruction.ModuleItems {
294+
if item.Item.ItemContent.TweetResults.Result.Typename == "Tweet" || item.Item.ItemContent.TweetResults.Result.Typename == "TweetWithVisibilityResults" {
295+
if tweet := item.Item.ItemContent.TweetResults.Result.parse(); tweet != nil {
296+
if item.Item.ItemContent.TweetDisplayType == "SelfThread" {
297+
tweet.IsSelfThread = true
298+
}
299+
tweets = append(tweets, tweet)
300+
}
301+
}
302+
303+
if item.Item.ItemContent.CursorType != "" && item.Item.ItemContent.Value != "" {
304+
threadID := ""
305+
306+
entryId := strings.Split(item.EntryID, "-")
307+
if len(entryId) > 1 && entryId[0] == "conversationthread" {
308+
if i, _ := strconv.Atoi(entryId[1]); i != 0 {
309+
threadID = entryId[1]
310+
}
311+
}
312+
313+
cursors = append(cursors, &ThreadCursor{
314+
FocalTweetID: focalTweetID,
315+
ThreadID: threadID,
316+
Cursor: item.Item.ItemContent.Value,
317+
CursorType: item.Item.ItemContent.CursorType,
318+
})
253319
}
254320
}
255321
}
322+
256323
for _, tweet := range tweets {
257324
if tweet.InReplyToStatusID != "" {
258325
for _, parentTweet := range tweets {
@@ -273,7 +340,7 @@ func (conversation *threadedConversation) parse() []*Tweet {
273340
}
274341
}
275342
}
276-
return tweets
343+
return tweets, cursors
277344
}
278345

279346
type tweetResult struct {

tweets.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ func (s *Scraper) GetTweet(id string) (*Tweet, error) {
199199
return nil, err
200200
}
201201

202-
tweets := conversation.parse()
202+
tweets, _ := conversation.parse(id)
203203
for _, tweet := range tweets {
204204
if tweet.ID == id {
205205
return tweet, nil

0 commit comments

Comments
 (0)