From ae031702869efd04a1853e3a3b9a9fdc9308393c Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 20 Mar 2023 00:55:58 +0100 Subject: [PATCH 01/29] Update deps --- nitter.nimble | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index fdc1f9f76..f9aa72aac 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -12,17 +12,17 @@ bin = @["nitter"] requires "nim >= 1.4.8" requires "jester#baca3f" -requires "karax#0af2c85" +requires "karax#9ee695b" requires "sass#7dfdd03" -requires "nimcrypto#b41129f" +requires "nimcrypto#4014ef9" requires "markdown#158efe3" requires "packedjson#9e6fbb6" requires "supersnappy#6c94198" requires "redpool#8b7c1db" requires "https://github.com/zedeus/redis#d0a0e6f" -requires "zippy#123cd59" -requires "flatty#9f885d7" -requires "jsony#d0e69bd" +requires "zippy#ca5989a" +requires "flatty#e668085" +requires "jsony#ea811be" # Tasks From 8fc3c3dec545927eb4b9acb4f18e15ab05054916 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 21 Mar 2023 10:35:30 +0100 Subject: [PATCH 02/29] Replace profile timeline with GraphQL endpoint --- src/api.nim | 19 +++++++---- src/consts.nim | 49 ++++++++++++++++++++++++++-- src/experimental/parser/graphql.nim | 1 + src/experimental/types/graphuser.nim | 1 + src/parser.nim | 43 ++++++++++++++++++++++-- src/routes/timeline.nim | 5 +-- src/tokens.nim | 2 +- src/types.nim | 1 + tests/test_profile.py | 11 ------- 9 files changed, 107 insertions(+), 25 deletions(-) diff --git a/src/api.nim b/src/api.nim index dfcf41366..3794aab6f 100644 --- a/src/api.nim +++ b/src/api.nim @@ -7,12 +7,9 @@ import experimental/parser as newParser proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let - variables = """{ - "screen_name": "$1", - "withSafetyModeUserFields": false, - "withSuperFollowsUserFields": false - }""" % [username] - js = await fetchRaw(graphUser ? {"variables": variables}, Api.userScreenName) + variables = %*{"screen_name": username} + params = {"variables": $variables, "features": userFeatures} + js = await fetchRaw(graphUser ? params, Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = @@ -22,6 +19,16 @@ proc getGraphUserById*(id: string): Future[User] {.async.} = js = await fetchRaw(graphUserById ? {"variables": variables}, Api.userRestId) result = parseGraphUser(js) +proc getGraphUserTweets*(id: string; after=""; replies=false): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = userTweetsVariables % [id, cursor] + params = {"variables": variables, "features": userTweetsFeatures} + url = if replies: graphUserTweetsAndReplies else: graphUserTweets + js = await fetch(url ? params, Api.tweetDetail) + result = parseGraphTimeline(js, after) + proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list, "withHighlightedLabel": false} diff --git a/src/consts.nim b/src/consts.nim index bb4e1a3c5..c6784dcb2 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -19,8 +19,10 @@ const tweet* = timelineApi / "conversation" graphql = api / "graphql" + graphUserTweets* = graphql / "9rys0A7w1EyqVd2ME0QCJg/UserTweets" + graphUserTweetsAndReplies* = graphql / "ehMCHF3Mkgjsfz_aImqOsg/UserTweetsAndReplies" graphTweet* = graphql / "6lWNh96EXDJCXl05SAtn_g/TweetDetail" - graphUser* = graphql / "7mjxD3-C6BxitPMVQ6w0-Q/UserByScreenName" + graphUser* = graphql / "nZjSkpOpSL5rWyIVdsKeLA/UserByScreenName" graphUserById* = graphql / "I5nvpI91ljifos1Y3Lltyg/UserByRestId" graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List" graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug" @@ -35,6 +37,7 @@ const "include_mute_edge": "0", "include_can_dm": "0", "include_can_media_tag": "1", + "include_ext_is_blue_verified": "true", "skip_status": "1", "cards_platform": "Web-12", "include_cards": "1", @@ -60,6 +63,40 @@ const ## photos: "result_filter: photos" ## videos: "result_filter: videos" + userTweetsVariables* = """{ + "userId": "$1", + $2 + "count": 20, + "includePromotedContent": false, + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false, + "withVoice": false, + "withV2Timeline": true +}""" + + userTweetsFeatures* = """{ + "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "responsive_web_graphql_exclude_directive_enabled": false, + "verified_phone_label_enabled": false, + "responsive_web_graphql_timeline_navigation_enabled": false, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, + "tweetypie_unmention_optimization_enabled": false, + "vibe_api_enabled": false, + "responsive_web_edit_tweet_api_enabled": false, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, + "view_counts_everywhere_api_enabled": false, + "longform_notetweets_consumption_enabled": true, + "tweet_awards_web_tipping_enabled": false, + "freedom_of_speech_not_reach_fetch_enabled": false, + "standardized_nudges_misinfo": false, + "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, + "interactive_text_enabled": false, + "responsive_web_text_conversations_enabled": false, + "longform_notetweets_richtext_consumption_enabled": false, + "responsive_web_enhance_cards_enabled": false +}""" + tweetVariables* = """{ "focalTweetId": "$1", $2 @@ -79,7 +116,7 @@ const "responsive_web_graphql_timeline_navigation_enabled": false, "standardized_nudges_misinfo": false, "verified_phone_label_enabled": false, - "responsive_web_twitter_blue_verified_badge_is_enabled": false, + "responsive_web_twitter_blue_verified_badge_is_enabled": true, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, "view_counts_everywhere_api_enabled": false, "responsive_web_edit_tweet_api_enabled": false, @@ -90,3 +127,11 @@ const "responsive_web_enhance_cards_enabled": false, "interactive_text_enabled": false }""" + + userFeatures* = """{ + "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "verified_phone_label_enabled": false, + "responsive_web_graphql_timeline_navigation_enabled": false, + "responsive_web_graphql_exclude_directive_enabled": true, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": true +}""" diff --git a/src/experimental/parser/graphql.nim b/src/experimental/parser/graphql.nim index 4431db3e0..36014e3b7 100644 --- a/src/experimental/parser/graphql.nim +++ b/src/experimental/parser/graphql.nim @@ -11,6 +11,7 @@ proc parseGraphUser*(json: string): User = result = toUser raw.data.user.result.legacy result.id = raw.data.user.result.restId + result.verified = result.verified or raw.data.user.result.isBlueVerified proc parseGraphListMembers*(json, cursor: string): Result[User] = result = Result[User]( diff --git a/src/experimental/types/graphuser.nim b/src/experimental/types/graphuser.nim index e13383aa2..478e7f360 100644 --- a/src/experimental/types/graphuser.nim +++ b/src/experimental/types/graphuser.nim @@ -11,4 +11,5 @@ type UserResult = object legacy*: RawUser restId*: string + isBlueVerified*: bool reason*: Option[string] diff --git a/src/parser.nim b/src/parser.nim index fa877f9f3..8439eb2f6 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -4,6 +4,8 @@ import packedjson, packedjson/deserialiser import types, parserutils, utils import experimental/parser/unifiedcard +proc parseGraphTweet(js: JsonNode): Tweet + proc parseUser(js: JsonNode; id=""): User = if js.isNull: return result = User( @@ -19,13 +21,20 @@ proc parseUser(js: JsonNode; id=""): User = tweets: js{"statuses_count"}.getInt, likes: js{"favourites_count"}.getInt, media: js{"media_count"}.getInt, - verified: js{"verified"}.getBool, + verified: js{"verified"}.getBool or js{"ext_is_blue_verified"}.getBool, protected: js{"protected"}.getBool, joinDate: js{"created_at"}.getTime ) result.expandUserEntities(js) +proc parseGraphUser(js: JsonNode): User = + let user = ? js{"user_results", "result"} + result = parseUser(user{"legacy"}) + + if "is_blue_verified" in user: + result.verified = true + proc parseGraphList*(js: JsonNode): List = if js.isNull: return @@ -213,10 +222,16 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = if js{"is_quote_status"}.getBool: result.quote = some Tweet(id: js{"quoted_status_id_str"}.getId) + # legacy with rt, js{"retweeted_status_id_str"}: result.retweet = some Tweet(id: rt.getId) return + # graphql + with rt, js{"retweeted_status_result", "result"}: + result.retweet = some parseGraphTweet(rt) + return + if jsCard.kind != JNull: let name = jsCard{"name"}.getStr if "poll" in name: @@ -237,7 +252,10 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = of "video": result.video = some(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: - result.attribution = some(parseUser(user)) + if user{"id"}.getInt > 0: + result.attribution = some(parseUser(user)) + else: + result.attribution = some(parseGraphUser(user)) of "animated_gif": result.gif = some(parseGif(m)) else: discard @@ -384,7 +402,7 @@ proc parseGraphTweet(js: JsonNode): Tweet = jsCard["binding_values"] = values result = parseTweet(js{"legacy"}, jsCard) - result.user = parseUser(js{"core", "user_results", "result", "legacy"}) + result.user = parseGraphUser(js{"core"}) with noteTweet, js{"note_tweet", "note_tweet_results", "result"}: result.expandNoteTweetEntities(noteTweet) @@ -435,3 +453,22 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.replies.content.add thread elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", "itemContent", "value"}.getStr + +proc parseGraphTimeline*(js: JsonNode; after=""): Timeline = + result = Timeline(beginning: after.len == 0) + + let instructions = ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"} + if instructions.len == 0: + return + + for e in instructions[instructions.len - 1]{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + let tweet = parseGraphTweet(e{"content", "itemContent", "tweet_results", "result"}) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet + elif entryId.startsWith("cursor-bottom"): + result.bottom = e{"content", "value"}.getStr + elif "cursor-top" notin entryId: + echo e diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index a0a6e2147..17e25bc43 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -47,8 +47,8 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; let timeline = case query.kind - of posts: getTimeline(userId, after) - of replies: getTimeline(userId, after, replies=true) + of posts: getGraphUserTweets(userId, after) + of replies: getGraphUserTweets(userId, after, replies=true) of media: getMediaTimeline(userId, after) else: getSearch[Tweet](query, after) @@ -64,6 +64,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; let tweet = await getCachedTweet(user.pinnedTweet) if not tweet.isNil: tweet.pinned = true + tweet.user = user pinned = some tweet result = Profile( diff --git a/src/tokens.nim b/src/tokens.nim index e6a444974..1cd9c6f5c 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -42,7 +42,7 @@ proc getPoolJson*(): JsonNode = maxReqs = case api of Api.listMembers, Api.listBySlug, Api.list, - Api.userRestId, Api.userScreenName, Api.tweetDetail: 500 + Api.userRestId, Api.userScreenName, Api.userTweets, Api.tweetDetail: 500 of Api.timeline: 187 else: 180 reqs = maxReqs - token.apis[api].remaining diff --git a/src/types.nim b/src/types.nim index 6f742d19e..276f77806 100644 --- a/src/types.nim +++ b/src/types.nim @@ -19,6 +19,7 @@ type listMembers userRestId userScreenName + userTweets status RateLimit* = object diff --git a/tests/test_profile.py b/tests/test_profile.py index e62f7b93b..f9b504725 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -17,11 +17,6 @@ invalid = [['thisprofiledoesntexist'], ['%']] -banner_color = [ - ['nim_lang', '22, 25, 32'], - ['rustlang', '35, 31, 32'] -] - banner_image = [ ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] ] @@ -74,12 +69,6 @@ def test_suspended(self): self.open_nitter('user') self.assert_text('User "user" has been suspended') - @parameterized.expand(banner_color) - def test_banner_color(self, username, color): - self.open_nitter(username) - banner = self.find_element(Profile.banner + ' a') - self.assertIn(color, banner.value_of_css_property('background-color')) - @parameterized.expand(banner_image) def test_banner_image(self, username, url): self.open_nitter(username) From 061694a571223eee95913d08e5fa963be2ee5a8b Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 02:49:12 +0100 Subject: [PATCH 03/29] Update GraphQL endpoint versions --- src/api.nim | 19 +++-------- src/consts.nim | 93 ++++++++++++++++++-------------------------------- src/parser.nim | 28 ++++++++------- src/types.nim | 4 +-- 4 files changed, 55 insertions(+), 89 deletions(-) diff --git a/src/api.nim b/src/api.nim index 3794aab6f..f10b498ac 100644 --- a/src/api.nim +++ b/src/api.nim @@ -24,9 +24,10 @@ proc getGraphUserTweets*(id: string; after=""; replies=false): Future[Timeline] let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" variables = userTweetsVariables % [id, cursor] - params = {"variables": variables, "features": userTweetsFeatures} - url = if replies: graphUserTweetsAndReplies else: graphUserTweets - js = await fetch(url ? params, Api.tweetDetail) + params = {"variables": variables, "features": tweetFeatures} + (url, apiId) = if replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies) + else: (graphUserTweets, Api.userTweets) + js = await fetch(url ? params, apiId) result = parseGraphTimeline(js, after) proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = @@ -65,18 +66,6 @@ proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = url = listTimeline ? ps result = parseTimeline(await fetch(url, Api.timeline), after) -proc getTimeline*(id: string; after=""; replies=false): Future[Timeline] {.async.} = - if id.len == 0: return - let - ps = genParams({"userId": id, "include_tweet_replies": $replies}, after) - url = timeline / (id & ".json") ? ps - result = parseTimeline(await fetch(url, Api.timeline), after) - -proc getMediaTimeline*(id: string; after=""): Future[Timeline] {.async.} = - if id.len == 0: return - let url = mediaTimeline / (id & ".json") ? genParams(cursor=after) - result = parseTimeline(await fetch(url, Api.timeline), after) - proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index c6784dcb2..864723126 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -7,23 +7,20 @@ const api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") - userShow* = api / "1.1/users/show.json" photoRail* = api / "1.1/statuses/media_timeline.json" status* = api / "1.1/statuses/show" search* = api / "2/search/adaptive.json" timelineApi = api / "2/timeline" - timeline* = timelineApi / "profile" mediaTimeline* = timelineApi / "media" listTimeline* = timelineApi / "list.json" - tweet* = timelineApi / "conversation" graphql = api / "graphql" graphUserTweets* = graphql / "9rys0A7w1EyqVd2ME0QCJg/UserTweets" graphUserTweetsAndReplies* = graphql / "ehMCHF3Mkgjsfz_aImqOsg/UserTweetsAndReplies" - graphTweet* = graphql / "6lWNh96EXDJCXl05SAtn_g/TweetDetail" - graphUser* = graphql / "nZjSkpOpSL5rWyIVdsKeLA/UserByScreenName" - graphUserById* = graphql / "I5nvpI91ljifos1Y3Lltyg/UserByRestId" + graphTweet* = graphql / "6I7Hm635Q6ftv69L8VrSeQ/TweetDetail" + graphUser* = graphql / "8mPfHBetXOg-EHAyeVxUoA/UserByScreenName" + graphUserById* = graphql / "nI8WydSd-X-lQIVo6bdktQ/UserByRestId" graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List" graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug" graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers" @@ -63,75 +60,53 @@ const ## photos: "result_filter: photos" ## videos: "result_filter: videos" - userTweetsVariables* = """{ - "userId": "$1", - $2 - "count": 20, - "includePromotedContent": false, - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withVoice": false, - "withV2Timeline": true + userFeatures* = """{ + "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "responsive_web_graphql_exclude_directive_enabled": true, + "responsive_web_graphql_skip_user_profile_image_extensions_enabled": true, + "responsive_web_graphql_timeline_navigation_enabled": false, + "verified_phone_label_enabled": false }""" - userTweetsFeatures* = """{ + tweetFeatures* = """{ + "longform_notetweets_consumption_enabled": true, + "longform_notetweets_richtext_consumption_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "freedom_of_speech_not_reach_fetch_enabled": false, + "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, + "interactive_text_enabled": false, + "responsive_web_edit_tweet_api_enabled": false, + "responsive_web_enhance_cards_enabled": false, "responsive_web_graphql_exclude_directive_enabled": false, - "verified_phone_label_enabled": false, - "responsive_web_graphql_timeline_navigation_enabled": false, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, - "tweetypie_unmention_optimization_enabled": false, - "vibe_api_enabled": false, - "responsive_web_edit_tweet_api_enabled": false, - "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, - "view_counts_everywhere_api_enabled": false, - "longform_notetweets_consumption_enabled": true, - "tweet_awards_web_tipping_enabled": false, - "freedom_of_speech_not_reach_fetch_enabled": false, + "responsive_web_graphql_timeline_navigation_enabled": false, + "responsive_web_text_conversations_enabled": false, "standardized_nudges_misinfo": false, + "tweet_awards_web_tipping_enabled": false, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, - "interactive_text_enabled": false, - "responsive_web_text_conversations_enabled": false, - "longform_notetweets_richtext_consumption_enabled": false, - "responsive_web_enhance_cards_enabled": false + "tweetypie_unmention_optimization_enabled": false, + "view_counts_everywhere_api_enabled": false, + "vibe_api_enabled": false, + "verified_phone_label_enabled": false }""" tweetVariables* = """{ "focalTweetId": "$1", $2 - "includePromotedContent": false, "withBirdwatchNotes": false, + "includePromotedContent": false, "withDownvotePerspective": false, "withReactionsMetadata": false, "withReactionsPerspective": false, - "withSuperFollowsTweetFields": false, - "withSuperFollowsUserFields": false, - "withVoice": false, - "withV2Timeline": true + "withVoice": false }""" - tweetFeatures* = """{ - "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, - "responsive_web_graphql_timeline_navigation_enabled": false, - "standardized_nudges_misinfo": false, - "verified_phone_label_enabled": false, - "responsive_web_twitter_blue_verified_badge_is_enabled": true, - "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, - "view_counts_everywhere_api_enabled": false, - "responsive_web_edit_tweet_api_enabled": false, - "tweetypie_unmention_optimization_enabled": false, - "vibe_api_enabled": false, - "longform_notetweets_consumption_enabled": true, - "responsive_web_text_conversations_enabled": false, - "responsive_web_enhance_cards_enabled": false, - "interactive_text_enabled": false -}""" - - userFeatures* = """{ - "responsive_web_twitter_blue_verified_badge_is_enabled": true, - "verified_phone_label_enabled": false, - "responsive_web_graphql_timeline_navigation_enabled": false, - "responsive_web_graphql_exclude_directive_enabled": true, - "responsive_web_graphql_skip_user_profile_image_extensions_enabled": true + userTweetsVariables* = """{ + "userId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false, + "withVoice": false }""" diff --git a/src/parser.nim b/src/parser.nim index 8439eb2f6..8ff272f78 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -428,7 +428,7 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) - let instructions = ? js{"data", "threaded_conversation_with_injections_v2", "instructions"} + let instructions = ? js{"data", "threaded_conversation_with_injections", "instructions"} if instructions.len == 0: return @@ -436,15 +436,16 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = let entryId = e{"entryId"}.getStr # echo entryId if entryId.startsWith("tweet"): - let tweet = parseGraphTweet(e{"content", "itemContent", "tweet_results", "result"}) + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) - if $tweet.id == tweetId: - result.tweet = tweet - else: - result.before.content.add tweet + if $tweet.id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: @@ -457,17 +458,18 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = proc parseGraphTimeline*(js: JsonNode; after=""): Timeline = result = Timeline(beginning: after.len == 0) - let instructions = ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"} + let instructions = ? js{"data", "user", "result", "timeline", "timeline", "instructions"} if instructions.len == 0: return for e in instructions[instructions.len - 1]{"entries"}: let entryId = e{"entryId"}.getStr if entryId.startsWith("tweet"): - let tweet = parseGraphTweet(e{"content", "itemContent", "tweet_results", "result"}) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) - result.content.add tweet + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet elif entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr elif "cursor-top" notin entryId: diff --git a/src/types.nim b/src/types.nim index 276f77806..d4c37edea 100644 --- a/src/types.nim +++ b/src/types.nim @@ -10,16 +10,16 @@ type Api* {.pure.} = enum tweetDetail - userShow timeline search - tweet list listBySlug listMembers userRestId userScreenName userTweets + userTweetsAndReplies + userMedia status RateLimit* = object From a5826a3c3d836ac243b879c90d629d149af9ef6d Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 03:01:34 +0100 Subject: [PATCH 04/29] Use GraphQL for profile media tab --- src/api.nim | 9 +++++++++ src/consts.nim | 6 ++---- src/routes/timeline.nim | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/api.nim b/src/api.nim index f10b498ac..8b00ac6c1 100644 --- a/src/api.nim +++ b/src/api.nim @@ -30,6 +30,15 @@ proc getGraphUserTweets*(id: string; after=""; replies=false): Future[Timeline] js = await fetch(url ? params, apiId) result = parseGraphTimeline(js, after) +proc getGraphUserMedia*(id: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = userTweetsVariables % [id, cursor] + params = {"variables": variables, "features": tweetFeatures} + js = await fetch(graphUserMedia ? params, Api.userMedia) + result = parseGraphTimeline(js, after) + proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list, "withHighlightedLabel": false} diff --git a/src/consts.nim b/src/consts.nim index 864723126..dffd85878 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -10,14 +10,12 @@ const photoRail* = api / "1.1/statuses/media_timeline.json" status* = api / "1.1/statuses/show" search* = api / "2/search/adaptive.json" - - timelineApi = api / "2/timeline" - mediaTimeline* = timelineApi / "media" - listTimeline* = timelineApi / "list.json" + listTimeline* = api / "2/timeline/list.json" graphql = api / "graphql" graphUserTweets* = graphql / "9rys0A7w1EyqVd2ME0QCJg/UserTweets" graphUserTweetsAndReplies* = graphql / "ehMCHF3Mkgjsfz_aImqOsg/UserTweetsAndReplies" + graphUserMedia* = graphql / "MA_EP2a21zpzNWKRkaPBMg/UserMedia" graphTweet* = graphql / "6I7Hm635Q6ftv69L8VrSeQ/TweetDetail" graphUser* = graphql / "8mPfHBetXOg-EHAyeVxUoA/UserByScreenName" graphUserById* = graphql / "nI8WydSd-X-lQIVo6bdktQ/UserByRestId" diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 17e25bc43..14e33d6c9 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -49,7 +49,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; case query.kind of posts: getGraphUserTweets(userId, after) of replies: getGraphUserTweets(userId, after, replies=true) - of media: getMediaTimeline(userId, after) + of media: getGraphUserMedia(userId, after) else: getSearch[Tweet](query, after) rail = From 482b2da0151a90de7484981c1056000af48dd6a8 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 13:04:11 +0100 Subject: [PATCH 05/29] Fix UserByRestId request --- src/api.nim | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api.nim b/src/api.nim index 8b00ac6c1..66c32eeb9 100644 --- a/src/api.nim +++ b/src/api.nim @@ -15,8 +15,9 @@ proc getGraphUser*(username: string): Future[User] {.async.} = proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - variables = """{"userId": "$1", "withSuperFollowsUserFields": true}""" % [id] - js = await fetchRaw(graphUserById ? {"variables": variables}, Api.userRestId) + variables = %*{"userId": id, "withSuperFollowsUserFields": true} + params = {"variables": $variables, "features": tweetFeatures} + js = await fetchRaw(graphUserById ? params, Api.userRestId) result = parseGraphUser(js) proc getGraphUserTweets*(id: string; after=""; replies=false): Future[Timeline] {.async.} = From 91c6d59da99e58d45eef04941dd32403d23a3131 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 17:02:55 +0100 Subject: [PATCH 06/29] Improve routing, fixes #814 --- src/nitter.nim | 10 +++++----- src/routes/timeline.nim | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/nitter.nim b/src/nitter.nim index 2e868a44b..f61a4a950 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -90,14 +90,14 @@ routes: resp Http429, showError( &"Instance has been rate limited.
Use {link} or try again later.", cfg) - extend unsupported, "" - extend preferences, "" - extend resolver, "" extend rss, "" + extend status, "" extend search, "" extend timeline, "" - extend list, "" - extend status, "" extend media, "" + extend list, "" + extend preferences, "" + extend resolver, "" extend embed, "" extend debug, "" + extend unsupported, "" diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 14e33d6c9..75dfa0ead 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -124,7 +124,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" - cond @"name" notin ["pic", "gif", "video"] + cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] cond @"tab" in ["with_replies", "media", "search", ""] let prefs = cookiePrefs() From a4966168de80699e6c8d34a36378205d2bc96693 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 19:12:16 +0100 Subject: [PATCH 07/29] Fix token pool JSON --- src/tokens.nim | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tokens.nim b/src/tokens.nim index 1cd9c6f5c..1447d66b6 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,10 +41,12 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.listMembers, Api.listBySlug, Api.list, - Api.userRestId, Api.userScreenName, Api.userTweets, Api.tweetDetail: 500 + of Api.status: 180 + of Api.search: 250 of Api.timeline: 187 - else: 180 + of Api.listMembers, Api.listBySlug, Api.list, Api.tweetDetail, + Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, + Api.userRestId, Api.userScreenName: 500 reqs = maxReqs - token.apis[api].remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs From 1f10c1fdffd82eff2a31d4d4dec260504ca75b89 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 19:18:42 +0100 Subject: [PATCH 08/29] Deduplicate GraphQL timeline endpoints --- src/api.nim | 27 ++++++++++----------------- src/consts.nim | 10 +--------- src/routes/timeline.nim | 6 +++--- src/types.nim | 5 +++++ 4 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/api.nim b/src/api.nim index 66c32eeb9..f2f6e856e 100644 --- a/src/api.nim +++ b/src/api.nim @@ -8,38 +8,31 @@ proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return let variables = %*{"screen_name": username} - params = {"variables": $variables, "features": userFeatures} + params = {"variables": $variables, "features": gqlFeatures} js = await fetchRaw(graphUser ? params, Api.userScreenName) result = parseGraphUser(js) proc getGraphUserById*(id: string): Future[User] {.async.} = if id.len == 0 or id.any(c => not c.isDigit): return let - variables = %*{"userId": id, "withSuperFollowsUserFields": true} - params = {"variables": $variables, "features": tweetFeatures} + variables = %*{"userId": id} + params = {"variables": $variables, "features": gqlFeatures} js = await fetchRaw(graphUserById ? params, Api.userRestId) result = parseGraphUser(js) -proc getGraphUserTweets*(id: string; after=""; replies=false): Future[Timeline] {.async.} = +proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timeline] {.async.} = if id.len == 0: return let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" variables = userTweetsVariables % [id, cursor] - params = {"variables": variables, "features": tweetFeatures} - (url, apiId) = if replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies) - else: (graphUserTweets, Api.userTweets) + params = {"variables": variables, "features": gqlFeatures} + (url, apiId) = case kind + of TimelineKind.tweets: (graphUserTweets, Api.userTweets) + of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies) + of TimelineKind.media: (graphUserMedia, Api.userMedia) js = await fetch(url ? params, apiId) result = parseGraphTimeline(js, after) -proc getGraphUserMedia*(id: string; after=""): Future[Timeline] {.async.} = - if id.len == 0: return - let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = userTweetsVariables % [id, cursor] - params = {"variables": variables, "features": tweetFeatures} - js = await fetch(graphUserMedia ? params, Api.userMedia) - result = parseGraphTimeline(js, after) - proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let variables = %*{"screenName": name, "listSlug": list, "withHighlightedLabel": false} @@ -112,7 +105,7 @@ proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = let cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" variables = tweetVariables % [id, cursor] - params = {"variables": variables, "features": tweetFeatures} + params = {"variables": variables, "features": gqlFeatures} js = await fetch(graphTweet ? params, Api.tweetDetail) result = parseGraphConversation(js, id) diff --git a/src/consts.nim b/src/consts.nim index dffd85878..2c9791677 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -58,15 +58,7 @@ const ## photos: "result_filter: photos" ## videos: "result_filter: videos" - userFeatures* = """{ - "responsive_web_twitter_blue_verified_badge_is_enabled": true, - "responsive_web_graphql_exclude_directive_enabled": true, - "responsive_web_graphql_skip_user_profile_image_extensions_enabled": true, - "responsive_web_graphql_timeline_navigation_enabled": false, - "verified_phone_label_enabled": false -}""" - - tweetFeatures* = """{ + gqlFeatures* = """{ "longform_notetweets_consumption_enabled": true, "longform_notetweets_richtext_consumption_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true, diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 75dfa0ead..c7f5a64fa 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -47,9 +47,9 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; let timeline = case query.kind - of posts: getGraphUserTweets(userId, after) - of replies: getGraphUserTweets(userId, after, replies=true) - of media: getGraphUserMedia(userId, after) + of posts: getGraphUserTweets(userId, TimelineKind.tweets, after) + of replies: getGraphUserTweets(userId, TimelineKind.replies, after) + of media: getGraphUserTweets(userId, TimelineKind.media, after) else: getSearch[Tweet](query, after) rail = diff --git a/src/types.nim b/src/types.nim index d4c37edea..6ac619362 100644 --- a/src/types.nim +++ b/src/types.nim @@ -8,6 +8,11 @@ type RateLimitError* = object of CatchableError InternalError* = object of CatchableError + TimelineKind* {.pure.} = enum + tweets + replies + media + Api* {.pure.} = enum tweetDetail timeline From 56f1ad424acfebc4b9ecbdc7ef0e5d5bcc957565 Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 19:19:15 +0100 Subject: [PATCH 09/29] Update list endpoints --- src/api.nim | 18 ++++++++---------- src/consts.nim | 6 +++--- src/parser.nim | 6 +++--- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/api.nim b/src/api.nim index f2f6e856e..a9bd34d2f 100644 --- a/src/api.nim +++ b/src/api.nim @@ -35,31 +35,29 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timel proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let - variables = %*{"screenName": name, "listSlug": list, "withHighlightedLabel": false} - url = graphListBySlug ? {"variables": $variables} - result = parseGraphList(await fetch(url, Api.listBySlug)) + variables = %*{"screenName": name, "listSlug": list} + params = {"variables": $variables, "features": gqlFeatures} + result = parseGraphList(await fetch(graphListBySlug ? params, Api.listBySlug)) proc getGraphList*(id: string): Future[List] {.async.} = let - variables = %*{"listId": id, "withHighlightedLabel": false} - url = graphList ? {"variables": $variables} - result = parseGraphList(await fetch(url, Api.list)) + variables = %*{"listId": id} + params = {"variables": $variables, "features": gqlFeatures} + result = parseGraphList(await fetch(graphListById ? params, Api.list)) proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} = if list.id.len == 0: return var variables = %*{ "listId": list.id, - "withSuperFollowsUserFields": false, "withBirdwatchPivots": false, "withDownvotePerspective": false, "withReactionsMetadata": false, - "withReactionsPerspective": false, - "withSuperFollowsTweetFields": false + "withReactionsPerspective": false } if after.len > 0: variables["cursor"] = % after - let url = graphListMembers ? {"variables": $variables} + let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = diff --git a/src/consts.nim b/src/consts.nim index 2c9791677..fe5757006 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -19,9 +19,9 @@ const graphTweet* = graphql / "6I7Hm635Q6ftv69L8VrSeQ/TweetDetail" graphUser* = graphql / "8mPfHBetXOg-EHAyeVxUoA/UserByScreenName" graphUserById* = graphql / "nI8WydSd-X-lQIVo6bdktQ/UserByRestId" - graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List" - graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug" - graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers" + graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" + graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" + graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" timelineParams* = { "include_profile_interstitial_type": "0", diff --git a/src/parser.nim b/src/parser.nim index 8ff272f78..e164ac0af 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -47,11 +47,11 @@ proc parseGraphList*(js: JsonNode): List = result = List( id: list{"id_str"}.getStr, name: list{"name"}.getStr, - username: list{"user", "legacy", "screen_name"}.getStr, - userId: list{"user", "rest_id"}.getStr, + username: list{"user_results", "result", "legacy", "screen_name"}.getStr, + userId: list{"user_results", "result", "rest_id"}.getStr, description: list{"description"}.getStr, members: list{"member_count"}.getInt, - banner: list{"custom_banner_media", "media_info", "url"}.getImageStr + banner: list{"custom_banner_media", "media_info", "original_img_url"}.getImageStr ) proc parsePoll(js: JsonNode): Poll = From 4332faea901ea3652b52b8c85ba870657521541a Mon Sep 17 00:00:00 2001 From: Zed Date: Wed, 22 Mar 2023 19:58:27 +0100 Subject: [PATCH 10/29] Use GraphQL for list tweets --- src/api.nim | 56 +++++++++++++++++++++++---------------------- src/consts.nim | 12 +++++++++- src/parser.nim | 7 ++++-- src/routes/list.nim | 3 +-- src/routes/rss.nim | 2 +- src/tokens.nim | 4 ++-- src/types.nim | 1 + 7 files changed, 50 insertions(+), 35 deletions(-) diff --git a/src/api.nim b/src/api.nim index a9bd34d2f..c4f27a9d2 100644 --- a/src/api.nim +++ b/src/api.nim @@ -31,7 +31,16 @@ proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Timel of TimelineKind.replies: (graphUserTweetsAndReplies, Api.userTweetsAndReplies) of TimelineKind.media: (graphUserMedia, Api.userMedia) js = await fetch(url ? params, apiId) - result = parseGraphTimeline(js, after) + result = parseGraphTimeline(js, "user", after) + +proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = listTweetsVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphListTweets ? params, Api.listTweets) + result = parseGraphTimeline(js, "list", after) proc getGraphListBySlug*(name, list: string): Future[List] {.async.} = let @@ -60,12 +69,27 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) -proc getListTimeline*(id: string; after=""): Future[Timeline] {.async.} = +proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let - ps = genParams({"list_id": id, "ranking_mode": "reverse_chronological"}, after) - url = listTimeline ? ps - result = parseTimeline(await fetch(url, Api.timeline), after) + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = tweetVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphTweet ? params, Api.tweetDetail) + result = parseGraphConversation(js, id) + +proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = + result = (await getGraphTweet(id, after)).replies + result.beginning = after.len == 0 + +proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = + result = await getGraphTweet(id) + if after.len > 0: + result.replies = await getReplies(id, after) + +proc getStatus*(id: string): Future[Tweet] {.async.} = + let url = status / (id & ".json") ? genParams() + result = parseStatus(await fetch(url, Api.status)) proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return @@ -98,28 +122,6 @@ proc getSearch*[T](query: Query; after=""): Future[Result[T]] {.async.} = except InternalError: return Result[T](beginning: true, query: query) -proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = - if id.len == 0: return - let - cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" - variables = tweetVariables % [id, cursor] - params = {"variables": variables, "features": gqlFeatures} - js = await fetch(graphTweet ? params, Api.tweetDetail) - result = parseGraphConversation(js, id) - -proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} = - result = (await getGraphTweet(id, after)).replies - result.beginning = after.len == 0 - -proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = - result = await getGraphTweet(id) - if after.len > 0: - result.replies = await getReplies(id, after) - -proc getStatus*(id: string): Future[Tweet] {.async.} = - let url = status / (id & ".json") ? genParams() - result = parseStatus(await fetch(url, Api.status)) - proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/consts.nim b/src/consts.nim index fe5757006..cec7fa687 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -10,7 +10,6 @@ const photoRail* = api / "1.1/statuses/media_timeline.json" status* = api / "1.1/statuses/show" search* = api / "2/search/adaptive.json" - listTimeline* = api / "2/timeline/list.json" graphql = api / "graphql" graphUserTweets* = graphql / "9rys0A7w1EyqVd2ME0QCJg/UserTweets" @@ -22,6 +21,7 @@ const graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" + graphListTweets* = graphql / "jZntL0oVJSdjhmPcdbw_eA/ListLatestTweetsTimeline" timelineParams* = { "include_profile_interstitial_type": "0", @@ -100,3 +100,13 @@ const "withReactionsPerspective": false, "withVoice": false }""" + + listTweetsVariables* = """{ + "listId": "$1", $2 + "count": 20, + "includePromotedContent": false, + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false, + "withVoice": false +}""" diff --git a/src/parser.nim b/src/parser.nim index e164ac0af..8a00f63d9 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -455,10 +455,13 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = elif entryId.startsWith("cursor-bottom"): result.replies.bottom = e{"content", "itemContent", "value"}.getStr -proc parseGraphTimeline*(js: JsonNode; after=""): Timeline = +proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline = result = Timeline(beginning: after.len == 0) - let instructions = ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + let instructions = + if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"} + else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + if instructions.len == 0: return diff --git a/src/routes/list.nim b/src/routes/list.nim index c97b1c1d1..ac3e97eca 100644 --- a/src/routes/list.nim +++ b/src/routes/list.nim @@ -6,7 +6,6 @@ import jester import router_utils import ".."/[types, redis_cache, api] import ../views/[general, timeline, list] -export getListTimeline, getGraphList template respList*(list, timeline, title, vnode: typed) = if list.id.len == 0 or list.name.len == 0: @@ -39,7 +38,7 @@ proc createListRouter*(cfg: Config) = let prefs = cookiePrefs() list = await getCachedList(id=(@"id")) - timeline = await getListTimeline(list.id, getCursor()) + timeline = await getGraphListTweets(list.id, getCursor()) vnode = renderTimelineTweets(timeline, prefs, request.path) respList(list, timeline, list.title, vnode) diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 5da29b0d1..8e7b05035 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -159,7 +159,7 @@ proc createRssRouter*(cfg: Config) = let list = await getCachedList(id=id) - timeline = await getListTimeline(list.id, cursor) + timeline = await getGraphListTweets(list.id, cursor) rss.cursor = timeline.bottom rss.feed = renderListRss(timeline.content, list, cfg) diff --git a/src/tokens.nim b/src/tokens.nim index 1447d66b6..db7c3931c 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -44,9 +44,9 @@ proc getPoolJson*(): JsonNode = of Api.status: 180 of Api.search: 250 of Api.timeline: 187 - of Api.listMembers, Api.listBySlug, Api.list, Api.tweetDetail, + of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets, Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, - Api.userRestId, Api.userScreenName: 500 + Api.userRestId, Api.userScreenName, Api.tweetDetail: 500 reqs = maxReqs - token.apis[api].remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs diff --git a/src/types.nim b/src/types.nim index 6ac619362..d047a7e06 100644 --- a/src/types.nim +++ b/src/types.nim @@ -20,6 +20,7 @@ type list listBySlug listMembers + listTweets userRestId userScreenName userTweets From bed060f052e2450aef9f62e0adfb92e1c3448296 Mon Sep 17 00:00:00 2001 From: Zed Date: Thu, 23 Mar 2023 03:19:43 +0100 Subject: [PATCH 11/29] Remove debug leftover --- src/consts.nim | 2 +- src/parser.nim | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/consts.nim b/src/consts.nim index cec7fa687..208642893 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -62,12 +62,12 @@ const "longform_notetweets_consumption_enabled": true, "longform_notetweets_richtext_consumption_enabled": true, "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "responsive_web_graphql_exclude_directive_enabled": true, "freedom_of_speech_not_reach_fetch_enabled": false, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, "interactive_text_enabled": false, "responsive_web_edit_tweet_api_enabled": false, "responsive_web_enhance_cards_enabled": false, - "responsive_web_graphql_exclude_directive_enabled": false, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": false, "responsive_web_text_conversations_enabled": false, diff --git a/src/parser.nim b/src/parser.nim index 8a00f63d9..3267fb45a 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -475,5 +475,3 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline = result.content.add tweet elif entryId.startsWith("cursor-bottom"): result.bottom = e{"content", "value"}.getStr - elif "cursor-top" notin entryId: - echo e From 5676ecc1f25fe9496e1920e692df199c2bd55c0d Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 24 Mar 2023 02:13:30 +0100 Subject: [PATCH 12/29] Replace old pinned tweet endpoint with GraphQL --- src/api.nim | 12 ++++++++---- src/consts.nim | 15 +++++++++++++-- src/parser.nim | 17 ++++------------- src/redis_cache.nim | 2 +- src/tokens.nim | 4 ++-- src/types.nim | 2 +- 6 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/api.nim b/src/api.nim index c4f27a9d2..92b0c7ca3 100644 --- a/src/api.nim +++ b/src/api.nim @@ -69,6 +69,14 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) +proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = + if id.len == 0: return + let + variables = tweetResultVariables % id + params = {"variables": variables, "features": gqlFeatures} + js = await fetch(graphTweetResult ? params, Api.tweetResult) + result = parseGraphTweetResult(js) + proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} = if id.len == 0: return let @@ -87,10 +95,6 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) -proc getStatus*(id: string): Future[Tweet] {.async.} = - let url = status / (id & ".json") ? genParams() - result = parseStatus(await fetch(url, Api.status)) - proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return let diff --git a/src/consts.nim b/src/consts.nim index 208642893..999a12e7f 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -12,12 +12,13 @@ const search* = api / "2/search/adaptive.json" graphql = api / "graphql" + graphUser* = graphql / "8mPfHBetXOg-EHAyeVxUoA/UserByScreenName" + graphUserById* = graphql / "nI8WydSd-X-lQIVo6bdktQ/UserByRestId" graphUserTweets* = graphql / "9rys0A7w1EyqVd2ME0QCJg/UserTweets" graphUserTweetsAndReplies* = graphql / "ehMCHF3Mkgjsfz_aImqOsg/UserTweetsAndReplies" graphUserMedia* = graphql / "MA_EP2a21zpzNWKRkaPBMg/UserMedia" graphTweet* = graphql / "6I7Hm635Q6ftv69L8VrSeQ/TweetDetail" - graphUser* = graphql / "8mPfHBetXOg-EHAyeVxUoA/UserByScreenName" - graphUserById* = graphql / "nI8WydSd-X-lQIVo6bdktQ/UserByRestId" + graphTweetResult* = graphql / "rt-rHeSJ-2H9O9gxWQcPcg/TweetResultByRestId" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" @@ -91,6 +92,16 @@ const "withVoice": false }""" + tweetResultVariables* = """{ + "tweetId": "$1", + "includePromotedContent": false, + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false, + "withVoice": false, + "withCommunity": false +}""" + userTweetsVariables* = """{ "userId": "$1", $2 "count": 20, diff --git a/src/parser.nim b/src/parser.nim index 3267fb45a..62df85410 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -317,19 +317,6 @@ proc parseGlobalObjects(js: JsonNode): GlobalObjects = tweet.user = result.users[tweet.user.id] result.tweets[k] = tweet -proc parseStatus*(js: JsonNode): Tweet = - with e, js{"errors"}: - if e.getError in {tweetNotFound, tweetUnavailable, tweetCensored, doesntExist, - tweetNotAuthorized, suspended}: - return - - result = parseTweet(js, js{"card"}) - if not result.isNil: - result.user = parseUser(js{"user"}) - - with quote, js{"quoted_status"}: - result.quote = some parseStatus(js{"quoted_status"}) - proc parseInstructions[T](res: var Result[T]; global: GlobalObjects; js: JsonNode) = if js.kind != JArray or js.len == 0: return @@ -425,6 +412,10 @@ proc parseGraphThread(js: JsonNode): tuple[thread: Chain; self: bool] = if t{"item", "itemContent", "tweetDisplayType"}.getStr == "SelfThread": result.self = true +proc parseGraphTweetResult*(js: JsonNode): Tweet = + with tweet, js{"data", "tweetResult", "result"}: + result = parseGraphTweet(tweet) + proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result = Conversation(replies: Result[Chain](beginning: true)) diff --git a/src/redis_cache.nim b/src/redis_cache.nim index 742b7aecb..89161be5c 100644 --- a/src/redis_cache.nim +++ b/src/redis_cache.nim @@ -153,7 +153,7 @@ proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = if tweet != redisNil: tweet.deserialize(Tweet) else: - result = await getStatus($id) + result = await getGraphTweetResult($id) if not result.isNil: await cache(result) diff --git a/src/tokens.nim b/src/tokens.nim index db7c3931c..d20420d00 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,12 +41,12 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.status: 180 of Api.search: 250 of Api.timeline: 187 of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets, Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, - Api.userRestId, Api.userScreenName, Api.tweetDetail: 500 + Api.userRestId, Api.userScreenName, + Api.tweetDetail, Api.tweetResult: 500 reqs = maxReqs - token.apis[api].remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs diff --git a/src/types.nim b/src/types.nim index d047a7e06..ef9b3a29d 100644 --- a/src/types.nim +++ b/src/types.nim @@ -15,6 +15,7 @@ type Api* {.pure.} = enum tweetDetail + tweetResult timeline search list @@ -26,7 +27,6 @@ type userTweets userTweetsAndReplies userMedia - status RateLimit* = object remaining*: int From 61d65dc8e144cdb3fce5db3d2e1400893c23c167 Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 25 Mar 2023 03:22:18 +0100 Subject: [PATCH 13/29] Validate tweet ID --- src/routes/status.nim | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/routes/status.nim b/src/routes/status.nim index 1104282c6..7e8922041 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -16,17 +16,21 @@ proc createStatusRouter*(cfg: Config) = router status: get "/@name/status/@id/?": cond '.' notin @"name" - cond not @"id".any(c => not c.isDigit) + let id = @"id" + + if id.len > 19 or id.any(c => not c.isDigit): + resp Http404, showError("Invalid tweet ID", cfg) + let prefs = cookiePrefs() # used for the infinite scroll feature if @"scroll".len > 0: - let replies = await getReplies(@"id", getCursor()) + let replies = await getReplies(id, getCursor()) if replies.content.len == 0: resp Http404, "" resp $renderReplies(replies, prefs, getPath()) - let conv = await getTweet(@"id", getCursor()) + let conv = await getTweet(id, getCursor()) if conv == nil: echo "nil conv" From 1f9d500d92eafd6922d11cf38db1adeed2a0609b Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 25 Mar 2023 14:45:36 +0100 Subject: [PATCH 14/29] Minor token handling fix --- src/apiutils.nim | 11 +++++++---- src/http_pool.nim | 6 ++++++ src/nitter.nim | 4 ++++ src/types.nim | 1 + 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 917932ab3..4fc63617f 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -44,7 +44,7 @@ proc genHeaders*(token: Token = nil): HttpHeaders = }) template updateToken() = - if api != Api.search and resp.headers.hasKey(rlRemaining): + if resp.headers.hasKey(rlRemaining): let remaining = parseInt(resp.headers[rlRemaining]) reset = parseInt(resp.headers[rlReset]) @@ -72,9 +72,9 @@ template fetchImpl(result, fetchBody) {.dirty.} = if resp.status == "401 Unauthorized" and result.len == 0: getContent() - if resp.status == $Http503: - badClient = true - raise newException(InternalError, result) + if resp.status == $Http503: + badClient = true + raise newException(BadClientError, "Bad client") if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": @@ -90,6 +90,9 @@ template fetchImpl(result, fetchBody) {.dirty.} = raise newException(InternalError, $url) except InternalError as e: raise e + except BadClientError as e: + release(token, used=true) + raise e except Exception as e: echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url if "length" notin e.msg and "descriptor" notin e.msg: diff --git a/src/http_pool.nim b/src/http_pool.nim index 203752060..b4e3cee16 100644 --- a/src/http_pool.nim +++ b/src/http_pool.nim @@ -42,5 +42,11 @@ template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped = except ProtocolError: # Twitter closed the connection, retry body + except BadClientError: + # Twitter returned 503, we need a new client + pool.release(c, true) + badClient = false + c = pool.acquire(heads) + body finally: pool.release(c, badClient) diff --git a/src/nitter.nim b/src/nitter.nim index f61a4a950..627af75b4 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -85,6 +85,10 @@ routes: resp Http500, showError( &"An error occurred, please {link} with the URL you tried to visit.", cfg) + error BadClientError: + echo error.exc.name, ": ", error.exc.msg + resp Http500, showError("Network error occured, please try again.", cfg) + error RateLimitError: const link = a("another instance", href = instancesUrl) resp Http429, showError( diff --git a/src/types.nim b/src/types.nim index ef9b3a29d..1e0d91328 100644 --- a/src/types.nim +++ b/src/types.nim @@ -7,6 +7,7 @@ genPrefsType() type RateLimitError* = object of CatchableError InternalError* = object of CatchableError + BadClientError* = object of CatchableError TimelineKind* {.pure.} = enum tweets From ec59942db8dbf43a4ca7b9b3870cc7612b6d0617 Mon Sep 17 00:00:00 2001 From: Zed Date: Sun, 26 Mar 2023 23:38:11 +0200 Subject: [PATCH 15/29] Hide US-only commerce cards --- src/experimental/parser/unifiedcard.nim | 2 ++ src/experimental/types/unifiedcard.nim | 6 ++++-- src/types.nim | 1 + src/views/tweet.nim | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/experimental/parser/unifiedcard.nim b/src/experimental/parser/unifiedcard.nim index 3c5158a1e..c9af43767 100644 --- a/src/experimental/parser/unifiedcard.nim +++ b/src/experimental/parser/unifiedcard.nim @@ -84,6 +84,8 @@ proc parseUnifiedCard*(json: string): Card = component.parseMedia(card, result) of buttonGroup: discard + of ComponentType.hidden: + result.kind = CardKind.hidden of ComponentType.unknown: echo "ERROR: Unknown component type: ", json diff --git a/src/experimental/types/unifiedcard.nim b/src/experimental/types/unifiedcard.nim index 4ec587c68..6e83cad58 100644 --- a/src/experimental/types/unifiedcard.nim +++ b/src/experimental/types/unifiedcard.nim @@ -17,6 +17,7 @@ type twitterListDetails communityDetails mediaWithDetailsHorizontal + hidden unknown Component* = object @@ -71,11 +72,11 @@ type Text = object content: string - HasTypeField = Component | Destination | MediaEntity | AppStoreData + TypeField = Component | Destination | MediaEntity | AppStoreData converter fromText*(text: Text): string = text.content -proc renameHook*(v: var HasTypeField; fieldName: var string) = +proc renameHook*(v: var TypeField; fieldName: var string) = if fieldName == "type": fieldName = "kind" @@ -89,6 +90,7 @@ proc enumHook*(s: string; v: var ComponentType) = of "twitter_list_details": twitterListDetails of "community_details": communityDetails of "media_with_details_horizontal": mediaWithDetailsHorizontal + of "commerce_drop_details": hidden else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown proc enumHook*(s: string; v: var AppType) = diff --git a/src/types.nim b/src/types.nim index 1e0d91328..40a260940 100644 --- a/src/types.nim +++ b/src/types.nim @@ -158,6 +158,7 @@ type imageDirectMessage = "image_direct_message" audiospace = "audiospace" newsletterPublication = "newsletter_publication" + hidden unknown Card* = object diff --git a/src/views/tweet.nim b/src/views/tweet.nim index ea94e28c3..7c4548fa8 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -328,7 +328,7 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0; if tweet.attribution.isSome: renderAttribution(tweet.attribution.get(), prefs) - if tweet.card.isSome: + if tweet.card.isSome and tweet.card.get().kind != hidden: renderCard(tweet.card.get(), prefs, path) if tweet.photos.len > 0: From 2ed1f63e99621d2b829b2833df4717a2b08eedc5 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 27 Mar 2023 02:03:08 +0200 Subject: [PATCH 16/29] Update config example --- nitter.example.conf | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nitter.example.conf b/nitter.example.conf index a7abea8e3..0d4deb7cf 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -1,11 +1,11 @@ [Server] +hostname = "nitter.net" # for generating links, change this to your own domain/ip +title = "nitter" address = "0.0.0.0" port = 8080 https = false # disable to enable cookies when not using https httpMaxConnections = 100 staticDir = "./public" -title = "nitter" -hostname = "nitter.net" [Cache] listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) @@ -13,9 +13,9 @@ rssMinutes = 10 # how long to cache rss queries redisHost = "localhost" # Change to "nitter-redis" if using docker-compose redisPort = 6379 redisPassword = "" -redisConnections = 20 # connection pool size +redisConnections = 20 # minimum open connections in pool redisMaxConnections = 30 -# max, new connections are opened when none are available, but if the pool size +# new connections are opened when none are available, but if the pool size # goes above this, they're closed when released. don't worry about this unless # you receive tons of requests per second @@ -23,15 +23,15 @@ redisMaxConnections = 30 hmacKey = "secretkey" # random key for cryptographic signing of video urls base64Media = false # use base64 encoding for proxied media urls enableRSS = true # set this to false to disable RSS feeds -enableDebug = false # enable request logs and debug endpoints +enableDebug = false # enable request logs and debug endpoints (/.tokens) proxy = "" # http/https url, SOCKS proxies are not supported proxyAuth = "" tokenCount = 10 # minimum amount of usable tokens. tokens are used to authorize API requests, -# but they expire after ~1 hour, and have a limit of 187 requests. -# the limit gets reset every 15 minutes, and the pool is filled up so there's -# always at least $tokenCount usable tokens. again, only increase this if -# you receive major bursts all the time +# but they expire after ~1 hour, and have a limit of 500 requests per endpoint. +# the limits reset every 15 minutes, and the pool is filled up so there's +# always at least `tokenCount` usable tokens. only increase this if you receive +# major bursts all the time and don't have a rate limiting setup via e.g. nginx # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] From b2580ed3ac68a31d80c49c6990eb65f7121f6ee3 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 27 Mar 2023 17:08:22 +0200 Subject: [PATCH 17/29] Remove http pool and gzip from token pool --- src/tokens.nim | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/tokens.nim b/src/tokens.nim index d20420d00..d93761bbf 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -1,8 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import asyncdispatch, httpclient, times, sequtils, json, random import strutils, tables -import zippy -import types, consts, http_pool +import types, consts const maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions @@ -11,11 +10,12 @@ const failDelay = initDuration(minutes=30) var - clientPool: HttpPool tokenPool: seq[Token] lastFailed: Time enableLogging = false +let headers = newHttpHeaders({"authorization": auth}) + template log(str) = if enableLogging: echo "[tokens] ", str @@ -67,18 +67,12 @@ proc fetchToken(): Future[Token] {.async.} = if getTime() - lastFailed < failDelay: raise rateLimitError() - let headers = newHttpHeaders({ - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "accept-encoding": "gzip", - "accept-language": "en-US,en;q=0.5", - "connection": "keep-alive", - "authorization": auth - }) + let client = newAsyncHttpClient(headers=headers) try: let - resp = clientPool.use(headers): await c.postContent(activate) - tokNode = parseJson(uncompress(resp))["guest_token"] + resp = await client.postContent(activate) + tokNode = parseJson(resp)["guest_token"] tok = tokNode.getStr($(tokNode.getInt)) time = getTime() @@ -88,6 +82,8 @@ proc fetchToken(): Future[Token] {.async.} = if "Try again" notin e.msg: echo "[tokens] fetching tokens paused, resuming in 30 minutes" lastFailed = getTime() + finally: + client.close() proc expired(token: Token): bool = let time = getTime() @@ -160,7 +156,6 @@ proc poolTokens*(amount: int) {.async.} = tokenPool.add newToken proc initTokenPool*(cfg: Config) {.async.} = - clientPool = HttpPool() enableLogging = cfg.enableDebug while true: From 892356e06daec9e83d634997fe0321409b7f1a28 Mon Sep 17 00:00:00 2001 From: Zed Date: Mon, 27 Mar 2023 18:13:43 +0200 Subject: [PATCH 18/29] Support tombstoned tweets in threads --- src/parser.nim | 12 ++++++++++++ src/parserutils.nim | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/parser.nim b/src/parser.nim index 62df85410..8b5a85591 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -437,6 +437,18 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = result.tweet = tweet else: result.before.content.add tweet + elif entryId.startsWith("tombstone"): + let id = entryId.getId() + let tweet = Tweet( + id: parseBiggestInt(id), + available: false, + text: e{"content", "itemContent"}.getTombstone + ) + + if id == tweetId: + result.tweet = tweet + else: + result.before.content.add tweet elif entryId.startsWith("conversationthread"): let (thread, self) = parseGraphThread(e) if self: diff --git a/src/parserutils.nim b/src/parserutils.nim index 8ae9cd0cf..28c6e4af7 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -133,6 +133,9 @@ proc getTombstone*(js: JsonNode): string = result = js{"tombstoneInfo", "richText", "text"}.getStr result.removeSuffix(" Learn more") + if result.len == 0: + result = js{"tombstoneInfo", "text"}.getStr + proc getMp4Resolution*(url: string): int = # parses the height out of a URL like this one: # https://video.twimg.com/ext_tw_video//pu/vid/720x1280/.mp4 From bb6f8a2de1c17e0fc12c5f9828c4ff22adaf83ee Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 28 Mar 2023 00:32:21 +0200 Subject: [PATCH 19/29] Retry GraphQL timeout errors --- src/apiutils.nim | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 4fc63617f..1b9c3a31b 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -67,14 +67,19 @@ template fetchImpl(result, fetchBody) {.dirty.} = getContent() - # Twitter randomly returns 401 errors with an empty body quite often. - # Retrying the request usually works. - if resp.status == "401 Unauthorized" and result.len == 0: - getContent() - - if resp.status == $Http503: - badClient = true - raise newException(BadClientError, "Bad client") + if not resp.status.startsWith("2"): + # Twitter sometimes times out, retry. + if resp.status == $Http400 and "TimeoutError" in result: + getContent() + + # Twitter randomly returns 401 errors with an empty body quite often. + # Retrying the request usually works. + if resp.status == $Http401 and result.len == 0: + getContent() + + if resp.status == $Http503: + badClient = true + raise newException(BadClientError, "Bad client") if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": From f0e1cb5ddb18009b87d1e99b06d957e2d14f6a56 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 28 Mar 2023 00:33:40 +0200 Subject: [PATCH 20/29] Remove unnecessary 401 retry --- src/apiutils.nim | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index 1b9c3a31b..ac779cce8 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -72,11 +72,6 @@ template fetchImpl(result, fetchBody) {.dirty.} = if resp.status == $Http400 and "TimeoutError" in result: getContent() - # Twitter randomly returns 401 errors with an empty body quite often. - # Retrying the request usually works. - if resp.status == $Http401 and result.len == 0: - getContent() - if resp.status == $Http503: badClient = true raise newException(BadClientError, "Bad client") From bb221fb99509e27d87211549c86b68fe551d7f10 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 28 Mar 2023 01:55:39 +0200 Subject: [PATCH 21/29] Remove broken timeout retry --- src/apiutils.nim | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/apiutils.nim b/src/apiutils.nim index ac779cce8..6df16fce6 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -67,14 +67,9 @@ template fetchImpl(result, fetchBody) {.dirty.} = getContent() - if not resp.status.startsWith("2"): - # Twitter sometimes times out, retry. - if resp.status == $Http400 and "TimeoutError" in result: - getContent() - - if resp.status == $Http503: - badClient = true - raise newException(BadClientError, "Bad client") + if resp.status == $Http503: + badClient = true + raise newException(BadClientError, "Bad client") if result.len > 0: if resp.headers.getOrDefault("content-encoding") == "gzip": From c8bc02c7f26888102b021b46d1736bb8053b44bf Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 28 Mar 2023 04:16:15 +0200 Subject: [PATCH 22/29] Update karax, use new bool attribute feature --- nitter.nimble | 2 +- src/views/renderutils.nim | 25 ++++++++----------------- src/views/search.nim | 10 ++++------ src/views/tweet.nim | 21 ++++++--------------- 4 files changed, 19 insertions(+), 39 deletions(-) diff --git a/nitter.nimble b/nitter.nimble index f9aa72aac..7771b3199 100644 --- a/nitter.nimble +++ b/nitter.nimble @@ -12,7 +12,7 @@ bin = @["nitter"] requires "nim >= 1.4.8" requires "jester#baca3f" -requires "karax#9ee695b" +requires "karax#5cf360c" requires "sass#7dfdd03" requires "nimcrypto#4014ef9" requires "markdown#158efe3" diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index cdfeb2854..9dffdcb20 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -59,8 +59,7 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod proc genCheckbox*(pref, label: string; state: bool): VNode = buildHtml(label(class="pref-group checkbox-container")): text label - if state: input(name=pref, `type`="checkbox", checked="") - else: input(name=pref, `type`="checkbox") + input(name=pref, `type`="checkbox", checked=state) span(class="checkbox") proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = @@ -68,20 +67,15 @@ proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true buildHtml(tdiv(class=("pref-group pref-input " & class))): if label.len > 0: label(`for`=pref): text label - if autofocus and state.len == 0: - input(name=pref, `type`="text", placeholder=p, value=state, autofocus="") - else: - input(name=pref, `type`="text", placeholder=p, value=state) + input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) proc genSelect*(pref, label, state: string; options: seq[string]): VNode = buildHtml(tdiv(class="pref-group pref-input")): label(`for`=pref): text label select(name=pref): for opt in options: - if opt == state: - option(value=opt, selected=""): text opt - else: - option(value=opt): text opt + option(value=opt, selected=(opt == state)): + text opt proc genDate*(pref, state: string): VNode = buildHtml(span(class="date-input")): @@ -93,12 +87,9 @@ proc genImg*(url: string; class=""): VNode = img(src=getPicUrl(url), class=class, alt="") proc getTabClass*(query: Query; tab: QueryKind): string = - result = "tab-item" - if query.kind == tab: - result &= " active" + if query.kind == tab: "tab-item active" + else: "tab-item" proc getAvatarClass*(prefs: Prefs): string = - if prefs.squareAvatars: - "avatar" - else: - "avatar round" + if prefs.squareAvatars: "avatar" + else: "avatar round" diff --git a/src/views/search.nim b/src/views/search.nim index 77ba14f5e..72c59f50e 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -63,12 +63,10 @@ proc renderSearchPanel*(query: Query): VNode = hiddenField("f", "tweets") genInput("q", "", query.text, "Enter search...", class="pref-inline") button(`type`="submit"): icon "search" - if isPanelOpen(query): - input(id="search-panel-toggle", `type`="checkbox", checked="") - else: - input(id="search-panel-toggle", `type`="checkbox") - label(`for`="search-panel-toggle"): - icon "down" + + input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query)) + label(`for`="search-panel-toggle"): icon "down" + tdiv(class="search-panel"): for f in @["filter", "exclude"]: span(class="search-title"): text capitalize(f) diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 7c4548fa8..3338b715c 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -106,14 +106,10 @@ proc renderVideo*(video: Video; prefs: Prefs; path: string): VNode = else: vidUrl case playbackType of mp4: - if prefs.muteVideos: - video(poster=thumb, controls="", muted=""): - source(src=source, `type`="video/mp4") - else: - video(poster=thumb, controls=""): - source(src=source, `type`="video/mp4") + video(poster=thumb, controls="", muted=prefs.muteVideos): + source(src=source, `type`="video/mp4") of m3u8, vmap: - video(poster=thumb, data-url=source, data-autoload="false") + video(poster=thumb, data-url=source, data-autoload="false", muted=prefs.muteVideos) verbatim "
" tdiv(class="overlay-circle"): span(class="overlay-triangle") verbatim "
" @@ -127,14 +123,9 @@ proc renderGif(gif: Gif; prefs: Prefs): VNode = buildHtml(tdiv(class="attachments media-gif")): tdiv(class="gallery-gif", style={maxHeight: "unset"}): tdiv(class="attachment"): - let thumb = getSmallPic(gif.thumb) - let url = getPicUrl(gif.url) - if prefs.autoplayGifs: - video(class="gif", poster=thumb, controls="", autoplay="", muted="", loop=""): - source(src=url, `type`="video/mp4") - else: - video(class="gif", poster=thumb, controls="", muted="", loop=""): - source(src=url, `type`="video/mp4") + video(class="gif", poster=getSmallPic(gif.thumb), autoplay=prefs.autoplayGifs, + controls="", muted="", loop=""): + source(src=getPicUrl(gif.url), `type`="video/mp4") proc renderPoll(poll: Poll): VNode = buildHtml(tdiv(class="poll")): From 64741de63902c45b913dabc8cd377be8f392b2e9 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 28 Mar 2023 17:43:31 +0200 Subject: [PATCH 23/29] Update card test --- tests/test_card.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_card.py b/tests/test_card.py index 51945d69d..da6ffde6c 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -3,11 +3,6 @@ card = [ - ['Thom_Wolf/status/1122466524860702729', - 'facebookresearch/fairseq', - 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.', - 'github.com', True], - ['nim_lang/status/1136652293510717440', 'Version 0.20.0 released', 'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!', @@ -25,6 +20,11 @@ ] no_thumb = [ + ['Thom_Wolf/status/1122466524860702729', + 'facebookresearch/fairseq', + 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.', + 'github.com'], + ['brent_p/status/1088857328680488961', 'Hts Nim Sugar', 'hts-nim is a library that allows one to use htslib via the nim programming language. Nim is a garbage-collected language that compiles to C and often has similar performance. I have become very...', From 34363a2b99cfdd4ea7438d5d9b0e5f2f0baae6aa Mon Sep 17 00:00:00 2001 From: Zed Date: Sat, 1 Apr 2023 18:50:04 +0200 Subject: [PATCH 24/29] Fix odd edgecase with broken retweets --- src/parser.nim | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 8b5a85591..ec30ed407 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -229,8 +229,10 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = # graphql with rt, js{"retweeted_status_result", "result"}: - result.retweet = some parseGraphTweet(rt) - return + # needed due to weird edgecase where the actual tweet data isn't included + if "legacy" in rt: + result.retweet = some parseGraphTweet(rt) + return if jsCard.kind != JNull: let name = jsCard{"name"}.getStr From 5dd85c63d76042b4e0a4b554fb1ded986078d4f4 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 21 Apr 2023 03:47:21 +0200 Subject: [PATCH 25/29] Replace search endpoints, switch Bearer token --- src/api.nim | 56 ++++++++++++++++----------- src/apiutils.nim | 4 +- src/consts.nim | 56 ++++++++++++--------------- src/experimental/parser.nim | 4 +- src/experimental/parser/timeline.nim | 30 -------------- src/experimental/parser/user.nim | 9 ++++- src/parser.nim | 58 +++++++++++++++++++++------- src/parserutils.nim | 5 +-- src/routes/rss.nim | 4 +- src/routes/search.nim | 4 +- src/routes/timeline.nim | 6 +-- src/tokens.nim | 4 +- src/types.nim | 1 + 13 files changed, 126 insertions(+), 115 deletions(-) delete mode 100644 src/experimental/parser/timeline.nim diff --git a/src/api.nim b/src/api.nim index 92b0c7ca3..ed6b4f695 100644 --- a/src/api.nim +++ b/src/api.nim @@ -69,6 +69,24 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) +proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} = + let q = genQueryParam(query) + if q.len == 0 or q == emptyQuery: return + var + variables = %*{ + "rawQuery": q, + "count": 20, + "product": "Latest", + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false + } + if after.len > 0: + variables["cursor"] = % after + let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + result = parseGraphSearch(await fetch(url, Api.search), after) + result.query = query + proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let @@ -99,32 +117,24 @@ proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = if name.len == 0: return let ps = genParams({"screen_name": name, "trim_user": "true"}, - count="18", ext=false) + count="18", ext=false) url = photoRail ? ps result = parsePhotoRail(await fetch(url, Api.timeline)) -proc getSearch*[T](query: Query; after=""): Future[Result[T]] {.async.} = - when T is User: - const - searchMode = ("result_filter", "user") - parse = parseUsers - fetchFunc = fetchRaw - else: - const - searchMode = ("tweet_search_mode", "live") - parse = parseTimeline - fetchFunc = fetch - - let q = genQueryParam(query) - if q.len == 0 or q == emptyQuery: - return Result[T](beginning: true, query: query) - - let url = search ? genParams(searchParams & @[("q", q), searchMode], after) - try: - result = parse(await fetchFunc(url, Api.search), after) - result.query = query - except InternalError: - return Result[T](beginning: true, query: query) +proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = + var url = userSearch ? { + "q": query.text, + "skip_status": "1", + "count": "20", + "page": page + } + + result = parseUsers(await fetchRaw(url, Api.userSearch)) + result.query = query + if page.len == 0: + result.bottom = "2" + elif page.allCharsInSet(Digits): + result.bottom = $(parseInt(page) + 1) proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) diff --git a/src/apiutils.nim b/src/apiutils.nim index 6df16fce6..66fc2de54 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -17,8 +17,8 @@ proc genParams*(pars: openArray[(string, string)] = @[]; cursor=""; result &= p if ext: result &= ("ext", "mediaStats") - result &= ("include_ext_alt_text", "true") - result &= ("include_ext_media_availability", "true") + result &= ("include_ext_alt_text", "1") + result &= ("include_ext_media_availability", "1") if count.len > 0: result &= ("count", count) if cursor.len > 0: diff --git a/src/consts.nim b/src/consts.nim index 999a12e7f..27e82f97e 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -1,15 +1,14 @@ # SPDX-License-Identifier: AGPL-3.0-only -import uri, sequtils +import uri, sequtils, strutils const - auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw" + auth* = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" api = parseUri("https://api.twitter.com") activate* = $(api / "1.1/guest/activate.json") photoRail* = api / "1.1/statuses/media_timeline.json" - status* = api / "1.1/statuses/show" - search* = api / "2/search/adaptive.json" + userSearch* = api / "1.1/users/search.json" graphql = api / "graphql" graphUser* = graphql / "8mPfHBetXOg-EHAyeVxUoA/UserByScreenName" @@ -19,6 +18,7 @@ const graphUserMedia* = graphql / "MA_EP2a21zpzNWKRkaPBMg/UserMedia" graphTweet* = graphql / "6I7Hm635Q6ftv69L8VrSeQ/TweetDetail" graphTweetResult* = graphql / "rt-rHeSJ-2H9O9gxWQcPcg/TweetResultByRestId" + graphSearchTimeline* = graphql / "gkjsKepM6gl_HmFWoWKfgg/SearchTimeline" graphListById* = graphql / "iTpgCtbdxrsJfyx0cFjHqg/ListByRestId" graphListBySlug* = graphql / "-kmqNvm5Y-cVrfvBy6docg/ListBySlug" graphListMembers* = graphql / "P4NpVZDqUD_7MEM84L-8nw/ListMembers" @@ -33,53 +33,46 @@ const "include_mute_edge": "0", "include_can_dm": "0", "include_can_media_tag": "1", - "include_ext_is_blue_verified": "true", + "include_ext_is_blue_verified": "1", "skip_status": "1", "cards_platform": "Web-12", "include_cards": "1", - "include_composer_source": "false", + "include_composer_source": "0", "include_reply_count": "1", "tweet_mode": "extended", - "include_entities": "true", - "include_user_entities": "true", - "include_ext_media_color": "false", - "send_error_codes": "true", - "simple_quoted_tweet": "true", - "include_quote_count": "true" + "include_entities": "1", + "include_user_entities": "1", + "include_ext_media_color": "0", + "send_error_codes": "1", + "simple_quoted_tweet": "1", + "include_quote_count": "1" }.toSeq - searchParams* = { - "query_source": "typed_query", - "pc": "1", - "spelling_corrections": "1" - }.toSeq - ## top: nothing - ## latest: "tweet_search_mode: live" - ## user: "result_filter: user" - ## photos: "result_filter: photos" - ## videos: "result_filter: videos" - gqlFeatures* = """{ - "longform_notetweets_consumption_enabled": true, - "longform_notetweets_richtext_consumption_enabled": true, - "responsive_web_twitter_blue_verified_badge_is_enabled": true, - "responsive_web_graphql_exclude_directive_enabled": true, + "blue_business_profile_image_shape_enabled": false, "freedom_of_speech_not_reach_fetch_enabled": false, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false, "interactive_text_enabled": false, + "longform_notetweets_consumption_enabled": true, + "longform_notetweets_richtext_consumption_enabled": true, + "longform_notetweets_rich_text_read_enabled": false, "responsive_web_edit_tweet_api_enabled": false, "responsive_web_enhance_cards_enabled": false, + "responsive_web_graphql_exclude_directive_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": false, "responsive_web_text_conversations_enabled": false, + "responsive_web_twitter_blue_verified_badge_is_enabled": true, + "spaces_2022_h2_clipping": true, + "spaces_2022_h2_spaces_communities": true, "standardized_nudges_misinfo": false, "tweet_awards_web_tipping_enabled": false, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false, "tweetypie_unmention_optimization_enabled": false, - "view_counts_everywhere_api_enabled": false, + "verified_phone_label_enabled": false, "vibe_api_enabled": false, - "verified_phone_label_enabled": false -}""" + "view_counts_everywhere_api_enabled": false +}""".replace(" ", "").replace("\n", "") tweetVariables* = """{ "focalTweetId": "$1", @@ -109,7 +102,8 @@ const "withDownvotePerspective": false, "withReactionsMetadata": false, "withReactionsPerspective": false, - "withVoice": false + "withVoice": false, + "withV2Timeline": true }""" listTweetsVariables* = """{ diff --git a/src/experimental/parser.nim b/src/experimental/parser.nim index 98ce7df7f..40986f55c 100644 --- a/src/experimental/parser.nim +++ b/src/experimental/parser.nim @@ -1,2 +1,2 @@ -import parser/[user, graphql, timeline] -export user, graphql, timeline +import parser/[user, graphql] +export user, graphql diff --git a/src/experimental/parser/timeline.nim b/src/experimental/parser/timeline.nim deleted file mode 100644 index 4663d005b..000000000 --- a/src/experimental/parser/timeline.nim +++ /dev/null @@ -1,30 +0,0 @@ -import std/[strutils, tables] -import jsony -import user, ../types/timeline -from ../../types import Result, User - -proc getId(id: string): string {.inline.} = - let start = id.rfind("-") - if start < 0: return id - id[start + 1 ..< id.len] - -proc parseUsers*(json: string; after=""): Result[User] = - result = Result[User](beginning: after.len == 0) - - let raw = json.fromJson(Search) - if raw.timeline.instructions.len == 0: - return - - for i in raw.timeline.instructions: - if i.addEntries.entries.len > 0: - for e in i.addEntries.entries: - let id = e.entryId.getId - if e.entryId.startsWith("user"): - if id in raw.globalObjects.users: - result.content.add toUser raw.globalObjects.users[id] - elif e.entryId.startsWith("cursor"): - let cursor = e.content.operation.cursor - if cursor.cursorType == "Top": - result.top = cursor.value - elif cursor.cursorType == "Bottom": - result.bottom = cursor.value diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index 715c9a965..a956aa85d 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -2,7 +2,7 @@ import std/[algorithm, unicode, re, strutils, strformat, options, nre] import jsony import utils, slices import ../types/user as userType -from ../../types import User, Error +from ../../types import Result, User, Error let unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" @@ -76,3 +76,10 @@ proc parseUser*(json: string; username=""): User = else: echo "[error - parseUser]: ", error result = toUser json.fromJson(RawUser) + +proc parseUsers*(json: string; after=""): Result[User] = + result = Result[User](beginning: after.len == 0) + + let raw = json.fromJson(seq[RawUser]) + for user in raw: + result.content.add user.toUser diff --git a/src/parser.nim b/src/parser.nim index ec30ed407..9efca9dd5 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -359,7 +359,7 @@ proc parseTimeline*(js: JsonNode; after=""): Timeline = result.top = e.getCursor elif "cursor-bottom" in entry: result.bottom = e.getCursor - elif entry.startsWith("sq-C"): + elif entry.startsWith("sq-cursor"): with cursor, e{"content", "operation", "cursor"}: if cursor{"cursorType"}.getStr == "Bottom": result.bottom = cursor{"value"}.getStr @@ -383,6 +383,12 @@ proc parseGraphTweet(js: JsonNode): Tweet = if js.kind == JNull or js{"__typename"}.getStr == "TweetUnavailable": return Tweet(available: false) + if js{"__typename"}.getStr == "TweetTombstone": + return Tweet( + available: false, + text: js{"tombstone", "text"}.getTombstone + ) + var jsCard = copy(js{"card", "legacy"}) if jsCard.kind != JNull: var values = newJObject() @@ -444,7 +450,7 @@ proc parseGraphConversation*(js: JsonNode; tweetId: string): Conversation = let tweet = Tweet( id: parseBiggestInt(id), available: false, - text: e{"content", "itemContent"}.getTombstone + text: e{"content", "itemContent", "tombstoneInfo", "richText"}.getTombstone ) if id == tweetId: @@ -465,18 +471,44 @@ proc parseGraphTimeline*(js: JsonNode; root: string; after=""): Timeline = let instructions = if root == "list": ? js{"data", "list", "tweets_timeline", "timeline", "instructions"} - else: ? js{"data", "user", "result", "timeline", "timeline", "instructions"} + else: ? js{"data", "user", "result", "timeline_v2", "timeline", "instructions"} if instructions.len == 0: return - for e in instructions[instructions.len - 1]{"entries"}: - let entryId = e{"entryId"}.getStr - if entryId.startsWith("tweet"): - with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: - let tweet = parseGraphTweet(tweetResult) - if not tweet.available: - tweet.id = parseBiggestInt(entryId.getId()) - result.content.add tweet - elif entryId.startsWith("cursor-bottom"): - result.bottom = e{"content", "value"}.getStr + for i in instructions: + if i{"type"}.getStr == "TimelineAddEntries": + for e in i{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet + elif entryId.startsWith("cursor-bottom"): + result.bottom = e{"content", "value"}.getStr + +proc parseGraphSearch*(js: JsonNode; after=""): Timeline = + result = Timeline(beginning: after.len == 0) + + let instructions = js{"data", "search_by_raw_query", "search_timeline", "timeline", "instructions"} + if instructions.len == 0: + return + + for instruction in instructions: + let typ = instruction{"type"}.getStr + if typ == "TimelineAddEntries": + for e in instructions[0]{"entries"}: + let entryId = e{"entryId"}.getStr + if entryId.startsWith("tweet"): + with tweetResult, e{"content", "itemContent", "tweet_results", "result"}: + let tweet = parseGraphTweet(tweetResult) + if not tweet.available: + tweet.id = parseBiggestInt(entryId.getId()) + result.content.add tweet + elif entryId.startsWith("cursor-bottom"): + result.bottom = e{"content", "value"}.getStr + elif typ == "TimelineReplaceEntry": + if instruction{"entry_id_to_replace"}.getStr.startsWith("cursor-bottom"): + result.bottom = instruction{"entry", "content", "value"}.getStr diff --git a/src/parserutils.nim b/src/parserutils.nim index 28c6e4af7..f28bd521e 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -130,12 +130,9 @@ proc getBanner*(js: JsonNode): string = return proc getTombstone*(js: JsonNode): string = - result = js{"tombstoneInfo", "richText", "text"}.getStr + result = js{"text"}.getStr result.removeSuffix(" Learn more") - if result.len == 0: - result = js{"tombstoneInfo", "text"}.getStr - proc getMp4Resolution*(url: string): int = # parses the height out of a URL like this one: # https://video.twimg.com/ext_tw_video//pu/vid/720x1280/.mp4 diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 8e7b05035..1323ed3e2 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -28,7 +28,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. var q = query q.fromUser = names profile = Profile( - tweets: await getSearch[Tweet](q, after), + tweets: await getGraphSearch(q, after), # this is kinda dumb user: User( username: name, @@ -78,7 +78,7 @@ proc createRssRouter*(cfg: Config) = if rss.cursor.len > 0: respRss(rss, "Search") - let tweets = await getSearch[Tweet](query, cursor) + let tweets = await getGraphSearch(query, cursor) rss.cursor = tweets.bottom rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg) diff --git a/src/routes/search.nim b/src/routes/search.nim index b2fd718cd..aeaad9de9 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -27,11 +27,11 @@ proc createSearchRouter*(cfg: Config) = of users: if "," in q: redirect("/" & q) - let users = await getSearch[User](query, getCursor()) + let users = await getUserSearch(query, getCursor()) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) of tweets: let - tweets = await getSearch[Tweet](query, getCursor()) + tweets = await getGraphSearch(query, getCursor()) rss = "/search/rss?" & genQueryUrl(query) resp renderMain(renderTweetSearch(tweets, prefs, getPath()), request, cfg, prefs, title, rss=rss) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index c7f5a64fa..331b8ae0b 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -50,7 +50,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; of posts: getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: getGraphUserTweets(userId, TimelineKind.replies, after) of media: getGraphUserTweets(userId, TimelineKind.media, after) - else: getSearch[Tweet](query, after) + else: getGraphSearch(query, after) rail = skipIf(skipRail or query.kind == media, @[]): @@ -83,7 +83,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; rss, after: string): Future[string] {.async.} = if query.fromUser.len != 1: let - timeline = await getSearch[Tweet](query, after) + timeline = await getGraphSearch(query, after) html = renderTweetSearch(timeline, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) @@ -138,7 +138,7 @@ proc createTimelineRouter*(cfg: Config) = # used for the infinite scroll feature if @"scroll".len > 0: if query.fromUser.len != 1: - var timeline = await getSearch[Tweet](query, after) + var timeline = await getGraphSearch(query, after) if timeline.content.len == 0: resp Http404 timeline.beginning = true resp $renderTweetSearch(timeline, prefs, getPath()) diff --git a/src/tokens.nim b/src/tokens.nim index d93761bbf..6ef81f5d4 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -41,12 +41,12 @@ proc getPoolJson*(): JsonNode = let maxReqs = case api - of Api.search: 250 of Api.timeline: 187 of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets, Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, Api.userRestId, Api.userScreenName, - Api.tweetDetail, Api.tweetResult: 500 + Api.tweetDetail, Api.tweetResult, Api.search: 500 + of Api.userSearch: 900 reqs = maxReqs - token.apis[api].remaining reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs diff --git a/src/types.nim b/src/types.nim index 40a260940..d2e7aad78 100644 --- a/src/types.nim +++ b/src/types.nim @@ -19,6 +19,7 @@ type tweetResult timeline search + userSearch list listBySlug listMembers From c8e8ea3adaa57b3cff321790568f3a29ff06904f Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 21 Apr 2023 04:08:06 +0200 Subject: [PATCH 26/29] Only parse user search if it's a list --- src/experimental/parser/user.nim | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index a956aa85d..ec35aae3b 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -80,6 +80,7 @@ proc parseUser*(json: string; username=""): User = proc parseUsers*(json: string; after=""): Result[User] = result = Result[User](beginning: after.len == 0) - let raw = json.fromJson(seq[RawUser]) - for user in raw: - result.content.add user.toUser + if json[0] == '[': + let raw = json.fromJson(seq[RawUser]) + for user in raw: + result.content.add user.toUser From 10a912d42e0e75a5543ec054ac111b9a2c967e5b Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 21 Apr 2023 04:08:25 +0200 Subject: [PATCH 27/29] Fix quoted tweet crash --- src/parser.nim | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 9efca9dd5..fe2fe5bdc 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -380,14 +380,19 @@ proc parsePhotoRail*(js: JsonNode): PhotoRail = result.add GalleryPhoto(url: url, tweetId: $t.id) proc parseGraphTweet(js: JsonNode): Tweet = - if js.kind == JNull or js{"__typename"}.getStr == "TweetUnavailable": + if js.kind == JNull: return Tweet(available: false) - if js{"__typename"}.getStr == "TweetTombstone": + case js{"__typename"}.getStr + of "TweetUnavailable": + return Tweet(available: false) + of "TweetTombstone": return Tweet( available: false, text: js{"tombstone", "text"}.getTombstone ) + of "TweetWithVisibilityResults": + return parseGraphTweet(js{"tweet"}) var jsCard = copy(js{"card", "legacy"}) if jsCard.kind != JNull: From 376b444500a6ef2a69e0a8f41e85ef8c8f6314b4 Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 21 Apr 2023 04:28:43 +0200 Subject: [PATCH 28/29] Fix empty search query handling --- src/api.nim | 55 +++++++++++++++++--------------- src/experimental/parser/user.nim | 7 ++-- src/types.nim | 1 + 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/api.nim b/src/api.nim index ed6b4f695..b23aa8730 100644 --- a/src/api.nim +++ b/src/api.nim @@ -69,24 +69,6 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) -proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} = - let q = genQueryParam(query) - if q.len == 0 or q == emptyQuery: return - var - variables = %*{ - "rawQuery": q, - "count": 20, - "product": "Latest", - "withDownvotePerspective": false, - "withReactionsMetadata": false, - "withReactionsPerspective": false - } - if after.len > 0: - variables["cursor"] = % after - let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} - result = parseGraphSearch(await fetch(url, Api.search), after) - result.query = query - proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let @@ -113,15 +95,30 @@ proc getTweet*(id: string; after=""): Future[Conversation] {.async.} = if after.len > 0: result.replies = await getReplies(id, after) -proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = - if name.len == 0: return - let - ps = genParams({"screen_name": name, "trim_user": "true"}, - count="18", ext=false) - url = photoRail ? ps - result = parsePhotoRail(await fetch(url, Api.timeline)) +proc getGraphSearch*(query: Query; after=""): Future[Result[Tweet]] {.async.} = + let q = genQueryParam(query) + if q.len == 0 or q == emptyQuery: + return Result[Tweet](query: query, beginning: true) + + var + variables = %*{ + "rawQuery": q, + "count": 20, + "product": "Latest", + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false + } + if after.len > 0: + variables["cursor"] = % after + let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + result = parseGraphSearch(await fetch(url, Api.search), after) + result.query = query proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = + if query.text.len == 0: + return Result[User](query: query, beginning: true) + var url = userSearch ? { "q": query.text, "skip_status": "1", @@ -136,6 +133,14 @@ proc getUserSearch*(query: Query; page="1"): Future[Result[User]] {.async.} = elif page.allCharsInSet(Digits): result.bottom = $(parseInt(page) + 1) +proc getPhotoRail*(name: string): Future[PhotoRail] {.async.} = + if name.len == 0: return + let + ps = genParams({"screen_name": name, "trim_user": "true"}, + count="18", ext=false) + url = photoRail ? ps + result = parsePhotoRail(await fetch(url, Api.timeline)) + proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} = let client = newAsyncHttpClient(maxRedirects=0) try: diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index ec35aae3b..a956aa85d 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -80,7 +80,6 @@ proc parseUser*(json: string; username=""): User = proc parseUsers*(json: string; after=""): Result[User] = result = Result[User](beginning: after.len == 0) - if json[0] == '[': - let raw = json.fromJson(seq[RawUser]) - for user in raw: - result.content.add user.toUser + let raw = json.fromJson(seq[RawUser]) + for user in raw: + result.content.add user.toUser diff --git a/src/types.nim b/src/types.nim index d2e7aad78..13d2f91b7 100644 --- a/src/types.nim +++ b/src/types.nim @@ -45,6 +45,7 @@ type null = 0 noUserMatches = 17 protectedUser = 22 + paramsMissing = 25 couldntAuth = 32 doesntExist = 34 userNotFound = 50 From 2f3ee727a35e447b212e06dfcaf3db75e2d7fe3f Mon Sep 17 00:00:00 2001 From: Zed Date: Fri, 21 Apr 2023 13:06:26 +0200 Subject: [PATCH 29/29] Fix invalid user search errors again --- src/experimental/parser/user.nim | 8 +++++--- src/routes/search.nim | 6 +++++- src/types.nim | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/experimental/parser/user.nim b/src/experimental/parser/user.nim index a956aa85d..b4d710f6c 100644 --- a/src/experimental/parser/user.nim +++ b/src/experimental/parser/user.nim @@ -80,6 +80,8 @@ proc parseUser*(json: string; username=""): User = proc parseUsers*(json: string; after=""): Result[User] = result = Result[User](beginning: after.len == 0) - let raw = json.fromJson(seq[RawUser]) - for user in raw: - result.content.add user.toUser + # starting with '{' means it's an error + if json[0] == '[': + let raw = json.fromJson(seq[RawUser]) + for user in raw: + result.content.add user.toUser diff --git a/src/routes/search.nim b/src/routes/search.nim index aeaad9de9..02c14e30d 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -27,7 +27,11 @@ proc createSearchRouter*(cfg: Config) = of users: if "," in q: redirect("/" & q) - let users = await getUserSearch(query, getCursor()) + var users: Result[User] + try: + users = await getUserSearch(query, getCursor()) + except InternalError: + users = Result[User](beginning: true, query: query) resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) of tweets: let diff --git a/src/types.nim b/src/types.nim index 13d2f91b7..4dca5f016 100644 --- a/src/types.nim +++ b/src/types.nim @@ -45,9 +45,10 @@ type null = 0 noUserMatches = 17 protectedUser = 22 - paramsMissing = 25 + missingParams = 25 couldntAuth = 32 doesntExist = 34 + invalidParam = 47 userNotFound = 50 suspended = 63 rateLimited = 88