Skip to content

Commit 31d210c

Browse files
authored
Add experimental x-client-transaction-id support (#1324)
* Add experimental x-client-transaction-id support * Remove broken test
1 parent dae68b4 commit 31d210c

File tree

13 files changed

+240
-158
lines changed

13 files changed

+240
-158
lines changed

nitter.example.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ enableRSS = true # set this to false to disable RSS feeds
2626
enableDebug = false # enable request logs and debug endpoints (/.sessions)
2727
proxy = "" # http/https url, SOCKS proxies are not supported
2828
proxyAuth = ""
29+
disableTid = false # enable this if cookie-based auth is failing
2930

3031
# Change default preferences here, see src/prefs_impl.nim for a complete list
3132
[Preferences]

src/api.nim

Lines changed: 66 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SPDX-License-Identifier: AGPL-3.0-only
2-
import asyncdispatch, httpclient, uri, strutils, sequtils, sugar, tables
2+
import asyncdispatch, httpclient, strutils, sequtils, sugar
33
import packedjson
44
import types, query, formatters, consts, apiutils, parser
55
import experimental/parser as newParser
@@ -11,95 +11,91 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
1111
if fieldToggles.len > 0:
1212
result.add ("fieldToggles", fieldToggles)
1313

14-
proc mediaUrl(id: string; cursor: string): SessionAwareUrl =
15-
let
16-
cookieVars = userMediaVars % [id, cursor]
17-
oauthVars = restIdVars % [id, cursor]
18-
result = SessionAwareUrl(
19-
cookieUrl: graphUserMedia ? genParams(cookieVars),
20-
oauthUrl: graphUserMediaV2 ? genParams(oauthVars)
14+
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl =
15+
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles))
16+
17+
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq =
18+
let url = apiUrl(endpoint, variables, fieldToggles)
19+
return ApiReq(cookie: url, oauth: url)
20+
21+
proc mediaUrl(id: string; cursor: string): ApiReq =
22+
result = ApiReq(
23+
cookie: apiUrl(graphUserMedia, userMediaVars % [id, cursor]),
24+
oauth: apiUrl(graphUserMediaV2, restIdVars % [id, cursor])
2125
)
2226

23-
proc userTweetsUrl(id: string; cursor: string): SessionAwareUrl =
24-
let
25-
cookieVars = userTweetsVars % [id, cursor]
26-
oauthVars = restIdVars % [id, cursor]
27-
result = SessionAwareUrl(
28-
# cookieUrl: graphUserTweets ? genParams(cookieVars, userTweetsFieldToggles),
29-
oauthUrl: graphUserTweetsV2 ? genParams(oauthVars)
27+
proc userTweetsUrl(id: string; cursor: string): ApiReq =
28+
result = ApiReq(
29+
# cookie: apiUrl(graphUserTweets, userTweetsVars % [id, cursor], userTweetsFieldToggles),
30+
oauth: apiUrl(graphUserTweetsV2, restIdVars % [id, cursor])
3031
)
3132
# might change this in the future pending testing
32-
result.cookieUrl = result.oauthUrl
33+
result.cookie = result.oauth
3334

34-
proc userTweetsAndRepliesUrl(id: string; cursor: string): SessionAwareUrl =
35-
let
36-
cookieVars = userTweetsAndRepliesVars % [id, cursor]
37-
oauthVars = restIdVars % [id, cursor]
38-
result = SessionAwareUrl(
39-
cookieUrl: graphUserTweetsAndReplies ? genParams(cookieVars, userTweetsFieldToggles),
40-
oauthUrl: graphUserTweetsAndRepliesV2 ? genParams(oauthVars)
35+
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
36+
let cookieVars = userTweetsAndRepliesVars % [id, cursor]
37+
result = ApiReq(
38+
cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
39+
oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor])
4140
)
4241

43-
proc tweetDetailUrl(id: string; cursor: string): SessionAwareUrl =
44-
let
45-
cookieVars = tweetDetailVars % [id, cursor]
46-
oauthVars = tweetVars % [id, cursor]
47-
result = SessionAwareUrl(
48-
cookieUrl: graphTweetDetail ? genParams(cookieVars, tweetDetailFieldToggles),
49-
oauthUrl: graphTweet ? genParams(oauthVars)
42+
proc tweetDetailUrl(id: string; cursor: string): ApiReq =
43+
let cookieVars = tweetDetailVars % [id, cursor]
44+
result = ApiReq(
45+
cookie: apiUrl(graphTweetDetail, cookieVars, tweetDetailFieldToggles),
46+
oauth: apiUrl(graphTweet, tweetVars % [id, cursor])
5047
)
5148

52-
proc userUrl(username: string): SessionAwareUrl =
53-
let
54-
cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
55-
oauthVars = """{"screen_name": "$1"}""" % username
56-
result = SessionAwareUrl(
57-
cookieUrl: graphUser ? genParams(cookieVars, tweetDetailFieldToggles),
58-
oauthUrl: graphUserV2 ? genParams(oauthVars)
49+
proc userUrl(username: string): ApiReq =
50+
let cookieVars = """{"screen_name":"$1","withGrokTranslatedBio":false}""" % username
51+
result = ApiReq(
52+
cookie: apiUrl(graphUser, cookieVars, tweetDetailFieldToggles),
53+
oauth: apiUrl(graphUserV2, """{"screen_name": "$1"}""" % username)
5954
)
6055

6156
proc getGraphUser*(username: string): Future[User] {.async.} =
6257
if username.len == 0: return
63-
let js = await fetchRaw(userUrl(username), Api.userScreenName)
58+
let js = await fetchRaw(userUrl(username))
6459
result = parseGraphUser(js)
6560

6661
proc getGraphUserById*(id: string): Future[User] {.async.} =
6762
if id.len == 0 or id.any(c => not c.isDigit): return
6863
let
69-
url = graphUserById ? genParams("""{"rest_id": "$1"}""" % id)
70-
js = await fetchRaw(url, Api.userRestId)
64+
url = apiReq(graphUserById, """{"rest_id": "$1"}""" % id)
65+
js = await fetchRaw(url)
7166
result = parseGraphUser(js)
7267

7368
proc getGraphUserTweets*(id: string; kind: TimelineKind; after=""): Future[Profile] {.async.} =
7469
if id.len == 0: return
7570
let
7671
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
77-
js = case kind
78-
of TimelineKind.tweets:
79-
await fetch(userTweetsUrl(id, cursor), Api.userTweets)
80-
of TimelineKind.replies:
81-
await fetch(userTweetsAndRepliesUrl(id, cursor), Api.userTweetsAndReplies)
82-
of TimelineKind.media:
83-
await fetch(mediaUrl(id, cursor), Api.userMedia)
72+
url = case kind
73+
of TimelineKind.tweets: userTweetsUrl(id, cursor)
74+
of TimelineKind.replies: userTweetsAndRepliesUrl(id, cursor)
75+
of TimelineKind.media: mediaUrl(id, cursor)
76+
js = await fetch(url)
8477
result = parseGraphTimeline(js, after)
8578

8679
proc getGraphListTweets*(id: string; after=""): Future[Timeline] {.async.} =
8780
if id.len == 0: return
8881
let
8982
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
90-
url = graphListTweets ? genParams(restIdVars % [id, cursor])
91-
result = parseGraphTimeline(await fetch(url, Api.listTweets), after).tweets
83+
url = apiReq(graphListTweets, restIdVars % [id, cursor])
84+
js = await fetch(url)
85+
result = parseGraphTimeline(js, after).tweets
9286

9387
proc getGraphListBySlug*(name, list: string): Future[List] {.async.} =
9488
let
9589
variables = %*{"screenName": name, "listSlug": list}
96-
url = graphListBySlug ? genParams($variables)
97-
result = parseGraphList(await fetch(url, Api.listBySlug))
90+
url = apiReq(graphListBySlug, $variables)
91+
js = await fetch(url)
92+
result = parseGraphList(js)
9893

9994
proc getGraphList*(id: string): Future[List] {.async.} =
100-
let
101-
url = graphListById ? genParams("""{"listId": "$1"}""" % id)
102-
result = parseGraphList(await fetch(url, Api.list))
95+
let
96+
url = apiReq(graphListById, """{"listId": "$1"}""" % id)
97+
js = await fetch(url)
98+
result = parseGraphList(js)
10399

104100
proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} =
105101
if list.id.len == 0: return
@@ -113,22 +109,23 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.}
113109
}
114110
if after.len > 0:
115111
variables["cursor"] = % after
116-
let url = graphListMembers ? genParams($variables)
117-
result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after)
112+
let
113+
url = apiReq(graphListMembers, $variables)
114+
js = await fetchRaw(url)
115+
result = parseGraphListMembers(js, after)
118116

119117
proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} =
120118
if id.len == 0: return
121119
let
122-
variables = """{"rest_id": "$1"}""" % id
123-
params = {"variables": variables, "features": gqlFeatures}
124-
js = await fetch(graphTweetResult ? params, Api.tweetResult)
120+
url = apiReq(graphTweetResult, """{"rest_id": "$1"}""" % id)
121+
js = await fetch(url)
125122
result = parseGraphTweetResult(js)
126123

127124
proc getGraphTweet(id: string; after=""): Future[Conversation] {.async.} =
128125
if id.len == 0: return
129126
let
130127
cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: ""
131-
js = await fetch(tweetDetailUrl(id, cursor), Api.tweetDetail)
128+
js = await fetch(tweetDetailUrl(id, cursor))
132129
result = parseGraphConversation(js, id)
133130

134131
proc getReplies*(id, after: string): Future[Result[Chain]] {.async.} =
@@ -157,8 +154,10 @@ proc getGraphTweetSearch*(query: Query; after=""): Future[Timeline] {.async.} =
157154
}
158155
if after.len > 0:
159156
variables["cursor"] = % after
160-
let url = graphSearchTimeline ? genParams($variables)
161-
result = parseGraphSearch[Tweets](await fetch(url, Api.search), after)
157+
let
158+
url = apiReq(graphSearchTimeline, $variables)
159+
js = await fetch(url)
160+
result = parseGraphSearch[Tweets](js, after)
162161
result.query = query
163162

164163
proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.} =
@@ -179,13 +178,15 @@ proc getGraphUserSearch*(query: Query; after=""): Future[Result[User]] {.async.}
179178
variables["cursor"] = % after
180179
result.beginning = false
181180

182-
let url = graphSearchTimeline ? genParams($variables)
183-
result = parseGraphSearch[User](await fetch(url, Api.search), after)
181+
let
182+
url = apiReq(graphSearchTimeline, $variables)
183+
js = await fetch(url)
184+
result = parseGraphSearch[User](js, after)
184185
result.query = query
185186

186187
proc getPhotoRail*(id: string): Future[PhotoRail] {.async.} =
187188
if id.len == 0: return
188-
let js = await fetch(mediaUrl(id, ""), Api.userMedia)
189+
let js = await fetch(mediaUrl(id, ""))
189190
result = parseGraphPhotoRail(js)
190191

191192
proc resolve*(url: string; prefs: Prefs): Future[string] {.async.} =

src/apiutils.nim

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPDX-License-Identifier: AGPL-3.0-only
22
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
33
import jsony, packedjson, zippy, oauth1
4-
import types, auth, consts, parserutils, http_pool
4+
import types, auth, consts, parserutils, http_pool, tid
55
import experimental/types/common
66

77
const
@@ -10,7 +10,21 @@ const
1010
rlLimit = "x-rate-limit-limit"
1111
errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
1212

13-
var pool: HttpPool
13+
var
14+
pool: HttpPool
15+
disableTid: bool
16+
17+
proc setDisableTid*(disable: bool) =
18+
disableTid = disable
19+
20+
proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
21+
case sessionKind
22+
of oauth:
23+
let o = req.oauth
24+
parseUri("https://api.x.com/graphql") / o.endpoint ? o.params
25+
of cookie:
26+
let c = req.cookie
27+
parseUri("https://x.com/i/api/graphql") / c.endpoint ? c.params
1428

1529
proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
1630
let
@@ -32,31 +46,36 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
3246
proc getCookieHeader(authToken, ct0: string): string =
3347
"auth_token=" & authToken & "; ct0=" & ct0
3448

35-
proc genHeaders*(session: Session, url: string): HttpHeaders =
49+
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
3650
result = newHttpHeaders({
3751
"connection": "keep-alive",
3852
"content-type": "application/json",
3953
"x-twitter-active-user": "yes",
4054
"x-twitter-client-language": "en",
41-
"authority": "api.x.com",
55+
"origin": "https://x.com",
4256
"accept-encoding": "gzip",
43-
"accept-language": "en-US,en;q=0.9",
57+
"accept-language": "en-US,en;q=0.5",
4458
"accept": "*/*",
4559
"DNT": "1",
4660
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
4761
})
4862

4963
case session.kind
5064
of SessionKind.oauth:
51-
result["authorization"] = getOauthHeader(url, session.oauthToken, session.oauthSecret)
65+
result["authority"] = "api.x.com"
66+
result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
5267
of SessionKind.cookie:
53-
result["authorization"] = "Bearer AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F"
5468
result["x-twitter-auth-type"] = "OAuth2Session"
5569
result["x-csrf-token"] = session.ct0
5670
result["cookie"] = getCookieHeader(session.authToken, session.ct0)
57-
58-
proc getAndValidateSession*(api: Api): Future[Session] {.async.} =
59-
result = await getSession(api)
71+
if disableTid:
72+
result["authorization"] = bearerToken2
73+
else:
74+
result["authorization"] = bearerToken
75+
result["x-client-transaction-id"] = await genTid(url.path)
76+
77+
proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
78+
result = await getSession(req)
6079
case result.kind
6180
of SessionKind.oauth:
6281
if result.oauthToken.len == 0:
@@ -73,7 +92,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
7392

7493
try:
7594
var resp: AsyncResponse
76-
pool.use(genHeaders(session, $url)):
95+
pool.use(await genHeaders(session, url)):
7796
template getContent =
7897
resp = await c.get($url)
7998
result = await resp.body
@@ -89,7 +108,7 @@ template fetchImpl(result, fetchBody) {.dirty.} =
89108
remaining = parseInt(resp.headers[rlRemaining])
90109
reset = parseInt(resp.headers[rlReset])
91110
limit = parseInt(resp.headers[rlLimit])
92-
session.setRateLimit(api, remaining, reset, limit)
111+
session.setRateLimit(req, remaining, reset, limit)
93112

94113
if result.len > 0:
95114
if resp.headers.getOrDefault("content-encoding") == "gzip":
@@ -98,24 +117,22 @@ template fetchImpl(result, fetchBody) {.dirty.} =
98117
if result.startsWith("{\"errors"):
99118
let errors = result.fromJson(Errors)
100119
if errors notin errorsToSkip:
101-
echo "Fetch error, API: ", api, ", errors: ", errors
120+
echo "Fetch error, API: ", url.path, ", errors: ", errors
102121
if errors in {expiredToken, badToken, locked}:
103122
invalidate(session)
104123
raise rateLimitError()
105124
elif errors in {rateLimited}:
106125
# rate limit hit, resets after 24 hours
107-
setLimited(session, api)
126+
setLimited(session, req)
108127
raise rateLimitError()
109128
elif result.startsWith("429 Too Many Requests"):
110-
echo "[sessions] 429 error, API: ", api, ", session: ", session.pretty
111-
session.apis[api].remaining = 0
112-
# rate limit hit, resets after the 15 minute window
129+
echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
113130
raise rateLimitError()
114131

115132
fetchBody
116133

117134
if resp.status == $Http400:
118-
echo "ERROR 400, ", api, ": ", result
135+
echo "ERROR 400, ", url.path, ": ", result
119136
raise newException(InternalError, $url)
120137
except InternalError as e:
121138
raise e
@@ -134,19 +151,16 @@ template retry(bod) =
134151
try:
135152
bod
136153
except RateLimitError:
137-
echo "[sessions] Rate limited, retrying ", api, " request..."
154+
echo "[sessions] Rate limited, retrying ", req.cookie.endpoint, " request..."
138155
bod
139156

140-
proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
157+
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
141158
retry:
142159
var
143160
body: string
144-
session = await getAndValidateSession(api)
161+
session = await getAndValidateSession(req)
145162

146-
when url is SessionAwareUrl:
147-
let url = case session.kind
148-
of SessionKind.oauth: url.oauthUrl
149-
of SessionKind.cookie: url.cookieUrl
163+
let url = req.toUrl(session.kind)
150164

151165
fetchImpl body:
152166
if body.startsWith('{') or body.startsWith('['):
@@ -157,19 +171,15 @@ proc fetch*(url: Uri | SessionAwareUrl; api: Api): Future[JsonNode] {.async.} =
157171

158172
let error = result.getError
159173
if error != null and error notin errorsToSkip:
160-
echo "Fetch error, API: ", api, ", error: ", error
174+
echo "Fetch error, API: ", url.path, ", error: ", error
161175
if error in {expiredToken, badToken, locked}:
162176
invalidate(session)
163177
raise rateLimitError()
164178

165-
proc fetchRaw*(url: Uri | SessionAwareUrl; api: Api): Future[string] {.async.} =
179+
proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
166180
retry:
167-
var session = await getAndValidateSession(api)
168-
169-
when url is SessionAwareUrl:
170-
let url = case session.kind
171-
of SessionKind.oauth: url.oauthUrl
172-
of SessionKind.cookie: url.cookieUrl
181+
var session = await getAndValidateSession(req)
182+
let url = req.toUrl(session.kind)
173183

174184
fetchImpl result:
175185
if not (result.startsWith('{') or result.startsWith('[')):

0 commit comments

Comments
 (0)