Skip to content

Commit 7383220

Browse files
committed
feat(transport): chromedp auto-bootstraps ct0/twid + per-op features
Live e2e against real X session surfaced multiple stacked issues. This commit lands the auth+transport layer that finally makes the read path work end to end (profile, tweets list, following, followers, search were tested live; see WORKS / KNOWN-FAILS at the bottom). ## Browser auth bootstrap (XActions parity) User pasted only auth_token. Previously x-cli refused to import without ct0+twid. The chromebrowser transport now mints them itself: internal/chromebrowser/browser.go Browser.Cookies(ctx, domain) — reads the in-memory Chrome cookie jar via CDP Network.GetCookies and returns a flat name→value map. Fetch JS — reads ct0 from document.cookie at request time and injects it as x-csrf-token. The browser populates ct0 itself via Set-Cookie on the initial navigate, so the user never has to paste a CSRF token. This matches XActions' Puppeteer flow. Navigation target switched from /robots.txt to /i/release_notes so the navigate triggers the session bootstrap that actually issues twid+gt+personalization_id alongside ct0. /robots.txt was too lightweight to populate twid. internal/chromebrowser/transport.go RoundTrip reads the browser cookie jar AFTER each Fetch and surfaces all cookies as Set-Cookie headers on the synthetic http.Response. The api.Client's existing mergeSetCookies path folds them into the in-memory Session — same code path as a real ct0 rotation. Net effect: VerifyCredentials runs a probe call, the response carries Set-Cookies for ct0+twid+gt+..., the merge populates session.Cookies, the probe-then-retry path in VerifyCredentials re-reads twid and resolves the user. X_CLI_BROWSER_DEBUG=1 prints the cookie jar names after each fetch (no values, just names) for diagnosing rotation issues. api/auth.go RequireAuthCookies (compat) → calls RequireAuthCookiesFor(true). RequireAuthCookiesFor(cookies, needCt0) — explicit form. The browser path passes needCt0=false because chromebrowser will fetch one itself. The http path still passes needCt0=true. VerifyCredentials restructured into two functions: verifyByTwid — the happy path when twid is present VerifyCredentials — orchestrates "no twid → probe → retry" errMissingTwid sentinel + isMissingTwid for the retry dispatch. cmd/auth.go acquireCookieString / runAuthImport now treat ct0 as optional in the browser path. Tighter timeout on the verify call (60s, accounts for first-call Chrome startup ~1-2s). cmd/doctor.go verifyLiveSession honors the global useHTTP flag — was hardcoding the http+utls path which hits Cloudflare from inside. ## Per-operation features blob (the stale-features bug) Different X GraphQL ops require different features blobs. Sending the union-of-all-keys causes the gateway to 404 unknown features for specific ops. Each op now carries its own override. api/endpoints.go GraphQLEndpoint adds Features map[string]bool. When non-nil it REPLACES the global features for that op. Documentation comment explains the rotation-recovery workflow. api/client.go GraphQL Looks up ep.Features first, falls back to c.endpoints.Features. endpoints.yaml Global features now matches the timeline operations' set (Followers/Following/SearchTimeline/UserTweets all use the same ~36-key blob). Profile-specific keys (hidden_profile_subscriptions, subscriptions_verification_info_*, highlights_tweets_tab_ui_enabled, responsive_web_twitter_article_notes_tab_enabled, subscriptions_feature_can_gift_premium) moved to UserByScreenName.features as an override. Followers query ID refreshed: `-WcGoRt8IQuPm-l1ymgy6g` (captured live; gC_lyAxZOptAMLCJX5UhWw is dead). ## Variable shape fixes api/relationships.go Followers, Following + withGrokTranslatedBio: true (required by current schema) api/search.go SearchPosts, SearchUsers + withGrokTranslatedBio: false api/tweets.go UserTweets includePromotedContent: false → true (matches live web client; schema rejection on false) ## Diagnostics api/client.go logRequest X_CLI_FULL_URL=1 disables the `?…` truncation so the full GraphQL URL with variables and features is logged. Used during this session to compare our URLs against captured live URLs. api/client.go GraphQL/REST 401/403/404 paths read up to 400 bytes of the response body and embed it in the error message via previewBody. Surfaced the Cloudflare HTML challenge in the v0.4→v0.5 transition. ## Live e2e results (Eric Wang's session) WORKS: ✓ x auth import --cookie 'auth_token=…' (no ct0 needed) ✓ x doctor (verify, ASN, TLS) ✓ x profile get jack (real data, all fields) ✓ x tweets list ericwang42 -n 10 (with new render: ↳, →q, 📷N, RT @author full body) ✓ x following jack -n 5 (Stella_Assange, Snowden, elonmusk — real data) KNOWN FAILS (next commit): ✗ x followers <user> → 404 empty body ✗ x search posts <query> → 404 empty body Root cause identified via CDP header capture: the live web client sends `x-client-transaction-id` on every request. UserTweets and Following accept its absence; Followers and SearchTimeline 404 when it's missing. The transaction ID is computed by an obfuscated function in X's main JS bundle. Two fixes are possible: (a) Reverse-engineer the algorithm and reproduce it in Go. The function uses HMAC over URL path + per-session animation hash + timestamp. Open-source ports exist (twikit, twitter-api-go). (b) Hijack the SPA's fetch wrapper from inside the page. Navigate to /jack/followers, let the SPA bootstrap, replace window.fetch with a recording wrapper, capture the next Followers call's headers including the JS-computed transaction ID, then run our own fetch with the captured headers. (b) is more reliable across rotations; (a) is more performant. Next commit picks one. Probably (b) for v0.7.
1 parent 9672a9f commit 7383220

11 files changed

Lines changed: 329 additions & 66 deletions

File tree

api/auth.go

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

33
import (
44
"context"
5+
"fmt"
56
"net/url"
67
"strings"
78
)
@@ -48,12 +49,28 @@ func ParseCookieString(s string) map[string]string {
4849
return out
4950
}
5051

51-
// RequireAuthCookies returns an AuthError if the required cookies are absent.
52+
// RequireAuthCookies returns an AuthError if the required cookies are
53+
// absent. `auth_token` is mandatory always. `ct0` is mandatory ONLY for
54+
// the http+utls transport — when the browser transport is in use, the
55+
// browser fetches a fresh ct0 from x.com on the first navigation, so
56+
// the caller can pass auth_token alone and let chromebrowser do the
57+
// rest.
58+
//
59+
// The caller signals which transport is in play via the boolean.
60+
// `false` (browser path) accepts an empty ct0; `true` (http path)
61+
// requires it.
5262
func RequireAuthCookies(cookies map[string]string) error {
63+
return RequireAuthCookiesFor(cookies, true)
64+
}
65+
66+
// RequireAuthCookiesFor is the explicit form. Pass needCt0 = false if
67+
// the caller will go through the browser transport (which can mint
68+
// ct0 on the fly via Set-Cookie from x.com).
69+
func RequireAuthCookiesFor(cookies map[string]string, needCt0 bool) error {
5370
if cookies["auth_token"] == "" {
5471
return &AuthError{Msg: "missing auth_token cookie"}
5572
}
56-
if cookies["ct0"] == "" {
73+
if needCt0 && cookies["ct0"] == "" {
5774
return &AuthError{Msg: "missing ct0 (CSRF) cookie"}
5875
}
5976
return nil
@@ -66,42 +83,77 @@ func RequireAuthCookies(cookies map[string]string) error {
6683
// (returns 404 as of April 2026). x-cli reads the `twid` cookie that
6784
// the user pasted, parses the numeric user id out of it, and calls
6885
// UserByRestId to confirm the session is alive AND fetch identity in
69-
// one round trip. Falls back to a UserByScreenName "twitter" probe if
70-
// `twid` is missing — that's the cheapest "is this cookie usable at
71-
// all" sanity check we have without it.
86+
// one round trip.
87+
//
88+
// When the user only provided `auth_token` (no twid — typical when
89+
// pasting from DevTools or when going through chromebrowser without
90+
// a stored twid), VerifyCredentials makes a cheap probe call. The
91+
// browser transport navigates to x.com on the first request, x.com
92+
// Set-Cookie's a fresh twid (and ct0, gt, etc.), the transport
93+
// surfaces those cookies back via Set-Cookie response headers, and
94+
// client.mergeSetCookies folds them into the session. We then
95+
// re-read twid and retry UserByRestId.
7296
func (c *Client) VerifyCredentials(ctx context.Context) (*User, error) {
97+
if user, err := c.verifyByTwid(ctx); err == nil {
98+
return user, nil
99+
} else if !isMissingTwid(err) {
100+
return nil, err
101+
}
102+
103+
// No twid yet. Run any cheap GraphQL call to make the browser
104+
// transport navigate to x.com — that's what triggers the
105+
// Set-Cookie response that populates twid + ct0 in the session.
106+
if _, err := c.GetProfile(ctx, "twitter"); err != nil {
107+
return nil, normaliseAuthError(err)
108+
}
109+
110+
// After the probe, the session should now have twid (merged in
111+
// via mergeSetCookies). Retry the proper verify.
112+
user, err := c.verifyByTwid(ctx)
113+
if err != nil {
114+
return nil, fmt.Errorf("after probe: %w", err)
115+
}
116+
return user, nil
117+
}
118+
119+
// verifyByTwid is the "have-twid" branch of VerifyCredentials. Returns
120+
// errMissingTwid when no usable twid is present, or any other error
121+
// from the underlying UserByRestId call.
122+
func (c *Client) verifyByTwid(ctx context.Context) (*User, error) {
73123
c.sessionMu.RLock()
74-
cookies := c.session.Cookies
75124
twidRaw := ""
76-
if cookies != nil {
77-
twidRaw = cookies["twid"]
125+
if c.session.Cookies != nil {
126+
twidRaw = c.session.Cookies["twid"]
78127
}
79128
c.sessionMu.RUnlock()
80129

81-
if userID := parseTwidUserID(twidRaw); userID != "" {
82-
p, err := c.GetProfileByID(ctx, userID)
83-
if err != nil {
84-
return nil, normaliseAuthError(err)
85-
}
86-
u := &User{
87-
ID: p.RestID,
88-
Username: p.ScreenName,
89-
Name: p.Name,
90-
}
91-
c.sessionMu.Lock()
92-
c.session.User = u
93-
c.sessionMu.Unlock()
94-
return u, nil
130+
userID := parseTwidUserID(twidRaw)
131+
if userID == "" {
132+
return nil, errMissingTwid
95133
}
96-
97-
// No twid: probe with a known account so we still detect a dead
98-
// cookie, but we cannot return identity in this branch.
99-
if _, err := c.GetProfile(ctx, "twitter"); err != nil {
134+
p, err := c.GetProfileByID(ctx, userID)
135+
if err != nil {
100136
return nil, normaliseAuthError(err)
101137
}
102-
return nil, &AuthError{Msg: "session is alive but `twid` cookie missing — re-import a complete cookie string"}
138+
u := &User{
139+
ID: p.RestID,
140+
Username: p.ScreenName,
141+
Name: p.Name,
142+
}
143+
c.sessionMu.Lock()
144+
c.session.User = u
145+
c.sessionMu.Unlock()
146+
return u, nil
103147
}
104148

149+
// errMissingTwid is the sentinel returned by verifyByTwid when no
150+
// usable twid cookie is present in the session. VerifyCredentials
151+
// uses isMissingTwid to detect it and trigger the probe-then-retry
152+
// path.
153+
var errMissingTwid = &AuthError{Msg: "twid cookie not yet present — probe needed"}
154+
155+
func isMissingTwid(err error) bool { return err == errMissingTwid }
156+
105157
// parseTwidUserID extracts the numeric user ID from a `twid` cookie value.
106158
// Twitter encodes it as `u%3D<NNN>` (URL-encoded `u=NNN`).
107159
//

api/client.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,18 @@ func (c *Client) mergeSetCookies(resp *http.Response) {
334334
// logRequest prints a one-line summary of an HTTP request to stderr if
335335
// Verbose is on. The query string is stripped because it carries the
336336
// GraphQL `variables` blob which can include user-controlled IDs that
337-
// are uninteresting noise. The Cookie header is never logged regardless
338-
// of verbosity — the only place we ever read it is applyHeaders.
337+
// are uninteresting noise — unless X_CLI_FULL_URL is set, in which
338+
// case we print the whole thing for debugging rotation drift. The
339+
// Cookie header is never logged regardless of verbosity — the only
340+
// place we ever read it is applyHeaders.
339341
func (c *Client) logRequest(method, rawURL string, status int, dur time.Duration, err error) {
340342
if !c.Verbose {
341343
return
342344
}
343-
if i := strings.IndexByte(rawURL, '?'); i > 0 {
344-
rawURL = rawURL[:i] + "?…"
345+
if os.Getenv("X_CLI_FULL_URL") == "" {
346+
if i := strings.IndexByte(rawURL, '?'); i > 0 {
347+
rawURL = rawURL[:i] + "?…"
348+
}
345349
}
346350
if err != nil {
347351
fmt.Fprintf(os.Stderr, "» %s %s → ERROR (%s): %v\n", method, rawURL, dur.Round(time.Millisecond), err)
@@ -398,7 +402,16 @@ func (c *Client) GraphQL(ctx context.Context, name string, variables map[string]
398402
if err != nil {
399403
return err
400404
}
401-
featsJSON, err := json.Marshal(c.endpoints.Features)
405+
// Per-op features override the global blob when present. Each
406+
// GraphQL operation expects a specific feature set; using one
407+
// global blob with the union of all keys causes the gateway to
408+
// 404 the request (it treats unknown-for-this-op features as a
409+
// schema mismatch).
410+
featuresMap := c.endpoints.Features
411+
if ep.Features != nil {
412+
featuresMap = ep.Features
413+
}
414+
featsJSON, err := json.Marshal(featuresMap)
402415
if err != nil {
403416
return err
404417
}

api/endpoints.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ type GraphQLEndpoint struct {
2828
Kind string `yaml:"kind"`
2929
RPS float64 `yaml:"rps"`
3030
Burst int `yaml:"burst"`
31+
32+
// Features is the per-operation features blob. When non-nil it
33+
// REPLACES the global EndpointMap.Features for this op. Different
34+
// X GraphQL operations expect different feature sets — passing a
35+
// "union of everything" blob causes the gateway to 404 the
36+
// request (it treats unknown features as a schema mismatch).
37+
// Capture the per-op set from the live web client and put it
38+
// here when an op stops working after a rotation.
39+
Features map[string]bool `yaml:"features,omitempty"`
3140
}
3241

3342
type RESTEndpoint struct {

api/relationships.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,23 @@ type PageOptions struct {
148148
}
149149

150150
// Followers scrapes the followers of `screenName`. Requires authentication.
151+
//
152+
// Variables match the modern web client (April 2026 capture):
153+
// `withGrokTranslatedBio: true` is required — without it x.com returns
154+
// 404 with an empty body (the gateway treats the missing var as an
155+
// unknown query).
151156
func (c *Client) Followers(ctx context.Context, screenName string, opts PageOptions) ([]*UserSummary, error) {
152157
uid, err := c.resolveUserID(ctx, screenName)
153158
if err != nil {
154159
return nil, err
155160
}
156161
return c.scrapeUserList(ctx, "Followers",
157-
map[string]any{"userId": uid, "count": 20, "includePromotedContent": false},
162+
map[string]any{
163+
"userId": uid,
164+
"count": 20,
165+
"includePromotedContent": false,
166+
"withGrokTranslatedBio": true,
167+
},
158168
opts,
159169
"data", "user", "result", "timeline", "timeline", "instructions")
160170
}
@@ -166,7 +176,12 @@ func (c *Client) Following(ctx context.Context, screenName string, opts PageOpti
166176
return nil, err
167177
}
168178
return c.scrapeUserList(ctx, "Following",
169-
map[string]any{"userId": uid, "count": 20, "includePromotedContent": false},
179+
map[string]any{
180+
"userId": uid,
181+
"count": 20,
182+
"includePromotedContent": false,
183+
"withGrokTranslatedBio": true,
184+
},
170185
opts,
171186
"data", "user", "result", "timeline", "timeline", "instructions")
172187
}

api/search.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,14 @@ func (c *Client) SearchPosts(ctx context.Context, query string, opts SearchOptio
5656
out := make([]*Tweet, 0, limit)
5757

5858
for len(out) < limit {
59+
// withGrokTranslatedBio is required by the modern gateway.
60+
// Sending it as `false` keeps the response shape compact.
5961
vars := map[string]any{
60-
"rawQuery": rawQuery,
61-
"count": 20,
62-
"querySource": "typed_query",
63-
"product": product,
62+
"rawQuery": rawQuery,
63+
"count": 20,
64+
"querySource": "typed_query",
65+
"product": product,
66+
"withGrokTranslatedBio": false,
6467
}
6568
if cursor != "" {
6669
vars["cursor"] = cursor
@@ -107,10 +110,11 @@ func (c *Client) SearchUsers(ctx context.Context, query string, opts SearchOptio
107110

108111
for len(out) < limit {
109112
vars := map[string]any{
110-
"rawQuery": query,
111-
"count": 20,
112-
"querySource": "typed_query",
113-
"product": "People",
113+
"rawQuery": query,
114+
"count": 20,
115+
"querySource": "typed_query",
116+
"product": "People",
117+
"withGrokTranslatedBio": false,
114118
}
115119
if cursor != "" {
116120
vars["cursor"] = cursor

api/tweets.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,10 +433,14 @@ func (c *Client) UserTweets(ctx context.Context, screenName string, opts Timelin
433433
if err != nil {
434434
return nil, err
435435
}
436+
// `includePromotedContent: true` matches the live web client.
437+
// Sending false used to work but no longer does — the gateway
438+
// returns a different schema variant. UserTweets in particular is
439+
// strict about this flag.
436440
return c.scrapeUserTimeline(ctx, "UserTweets", map[string]any{
437441
"userId": userID,
438442
"count": 20,
439-
"includePromotedContent": false,
443+
"includePromotedContent": true,
440444
"withQuickPromoteEligibilityTweetFields": true,
441445
"withVoice": true,
442446
}, opts)

cmd/auth.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,11 @@ func runAuthImport(cmd *cobra.Command, _ []string) error {
145145
}
146146

147147
cookies := api.ParseCookieString(raw)
148-
if err := api.RequireAuthCookies(cookies); err != nil {
148+
// In the browser path, ct0 is optional — chromebrowser fetches a
149+
// fresh one from x.com via Set-Cookie on the first navigation.
150+
// In the http path, ct0 is mandatory because the http transport
151+
// has no way to mint one.
152+
if err := api.RequireAuthCookiesFor(cookies, useHTTP); err != nil {
149153
return err
150154
}
151155

cmd/doctor.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,12 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
102102

103103
func verifyLiveSession(ctx context.Context, eps *api.EndpointMap, sess *store.Session) error {
104104
client := api.New(api.Options{
105-
Endpoints: eps,
106-
Throttle: api.NewThrottle(api.Defaults{}),
107-
Session: api.Session{Cookies: sess.Cookies},
105+
Endpoints: eps,
106+
Throttle: api.NewThrottle(api.Defaults{}),
107+
Session: api.Session{Cookies: sess.Cookies},
108+
UseBrowser: !useHTTP,
108109
})
109-
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
110+
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
110111
defer cancel()
111112
_, err := client.VerifyCredentials(ctx)
112113
return err

endpoints.yaml

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,22 @@ bases:
2525
# twikit and twitter-scraper. Rotate if X revokes it.
2626
bearer: "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
2727

28-
# GraphQL features blob captured from the live web client. Must match
29-
# what the gateway expects at the time of the bundle. If the server
30-
# rejects a query with a "feature error", append the missing key here.
28+
# Default GraphQL features blob — the "timeline" set captured from
29+
# Followers/Following/SearchTimeline/UserTweets/BlueVerifiedFollowers
30+
# requests in the live web client. Profile-page ops (UserByScreenName,
31+
# UserByRestId) use their own override under the per-op features key
32+
# because they need a different set (see below).
33+
#
34+
# Profile-only keys NOT included here:
35+
# hidden_profile_subscriptions_enabled
36+
# subscriptions_verification_info_is_identity_verified_enabled
37+
# subscriptions_verification_info_verified_since_enabled
38+
# highlights_tweets_tab_ui_enabled
39+
# responsive_web_twitter_article_notes_tab_enabled
40+
# subscriptions_feature_can_gift_premium
41+
#
42+
# Adding them here breaks Followers/SearchTimeline (the gateway returns
43+
# 404 with empty body when an op receives features it doesn't expect).
3144
features:
3245
rweb_video_screen_enabled: false
3346
profile_label_improvements_pcf_label_in_post_enabled: true
@@ -65,12 +78,6 @@ features:
6578
responsive_web_grok_imagine_annotation_enabled: true
6679
responsive_web_grok_community_note_auto_translation_is_enabled: true
6780
responsive_web_enhance_cards_enabled: false
68-
hidden_profile_subscriptions_enabled: true
69-
subscriptions_verification_info_is_identity_verified_enabled: true
70-
subscriptions_verification_info_verified_since_enabled: true
71-
highlights_tweets_tab_ui_enabled: true
72-
responsive_web_twitter_article_notes_tab_enabled: true
73-
subscriptions_feature_can_gift_premium: true
7481

7582
# GraphQL queries and mutations.
7683
#
@@ -85,6 +92,24 @@ graphql:
8592
kind: read
8693
rps: 0.5
8794
burst: 2
95+
# Profile-specific features blob. Different from the global
96+
# "timeline" set: includes hidden_profile_subscriptions and
97+
# subscriptions_verification_info_* which the timeline ops
98+
# reject as unknown.
99+
features:
100+
hidden_profile_subscriptions_enabled: true
101+
profile_label_improvements_pcf_label_in_post_enabled: true
102+
responsive_web_profile_redirect_enabled: false
103+
rweb_tipjar_consumption_enabled: false
104+
verified_phone_label_enabled: false
105+
subscriptions_verification_info_is_identity_verified_enabled: true
106+
subscriptions_verification_info_verified_since_enabled: true
107+
highlights_tweets_tab_ui_enabled: true
108+
responsive_web_twitter_article_notes_tab_enabled: true
109+
subscriptions_feature_can_gift_premium: true
110+
creator_subscriptions_tweet_preview_api_enabled: true
111+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false
112+
responsive_web_graphql_timeline_navigation_enabled: true
88113
UserByRestId:
89114
queryId: VQfQ9wwYdk6j_u2O4vt64Q
90115
operationName: UserByRestId
@@ -128,7 +153,7 @@ graphql:
128153
rps: 0.3
129154
burst: 2
130155
Followers:
131-
queryId: gC_lyAxZOptAMLCJX5UhWw
156+
queryId: "-WcGoRt8IQuPm-l1ymgy6g"
132157
operationName: Followers
133158
kind: read
134159
rps: 0.2

0 commit comments

Comments
 (0)