xapiweb 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ """Tweets: read, post, delete, pin, reply-controls, disclosures."""
2
+ from .. import errors as E
3
+ from ._base import BaseResource
4
+ from .media import parse_media_entities
5
+
6
+ REPLY_MODES = ("ByInvitation", "Verified", "Subscribers", "Community", "MyNetwork", "CountryOfOrigin")
7
+
8
+
9
+ class Tweets(BaseResource):
10
+ # ---------------- read ----------------
11
+ def get(self, tweet_id):
12
+ """Tweet by id. Proven: test_tweet_by_id.py"""
13
+ return self._s.gql_get("TweetResultByRestId", {
14
+ "tweetId": str(tweet_id), "withCommunity": False,
15
+ "withVoice": True, "includePromotedContent": False})
16
+
17
+ def get_many(self, tweet_ids):
18
+ """Batch tweets by ids. Proven: test_tweet_results_by_rest_ids.py"""
19
+ return self._s.gql_get("TweetResultsByRestIds", {
20
+ "tweetIds": [str(i) for i in tweet_ids], "includePromotedContent": False,
21
+ "withVoice": True, "withCommunity": False})
22
+
23
+ def detail(self, focal_tweet_id):
24
+ """Conversation/detail (thread + replies). Proven: test_tweet_detail.py"""
25
+ return self._s.gql_get("TweetDetail", {
26
+ "focalTweetId": str(focal_tweet_id), "with_rux_injections": False,
27
+ "rankingMode": "Relevance", "includePromotedContent": True,
28
+ "withCommunity": True, "withQuickPromoteEligibilityTweetFields": True,
29
+ "withVoice": True, "withBirdwatchNotes": False})
30
+
31
+ def oembed(self, tweet_url):
32
+ """oEmbed card for a tweet URL. Proven: test_oembed.py"""
33
+ return self._s.rest_get("statuses/oembed.json", {"url": tweet_url})
34
+
35
+ def quick_promote_eligibility(self, tweet_id):
36
+ """Proven: test_quick_promote_eligibility.py"""
37
+ return self._s.gql_get("QuickPromoteEligibility", {"tweetId": str(tweet_id)},
38
+ with_features=False, with_toggles=False)
39
+
40
+ def moderated_view(self, root_tweet_id, count=5):
41
+ """Proven: test_moderated_timeline.py"""
42
+ return self._s.gql_get("ModeratedTimeline", {
43
+ "rootTweetId": str(root_tweet_id), "count": count, "includePromotedContent": False})
44
+
45
+ def similar(self, tweet_id):
46
+ """Similar posts (snake_case var!). Proven: test_similar_posts.py"""
47
+ return self._s.gql_get("SimilarPosts", {"tweet_id": str(tweet_id)})
48
+
49
+ def views(self, tweet_id):
50
+ """Impressions/views count (int) or None if unavailable.
51
+ Lives at result.views.count (sibling of legacy, NOT inside it).
52
+ Proven live by xapiweb 2026-09-20."""
53
+ r = self.get(tweet_id)
54
+ v = r.get("data", "tweetResult", "result", "views", default=None) or {}
55
+ try:
56
+ return int(v.get("count"))
57
+ except (TypeError, ValueError):
58
+ return None
59
+
60
+ def stats(self, tweet_id):
61
+ """All public counts in ONE call: views/likes/retweets/replies/quotes/bookmarks.
62
+ Proven live by xapiweb 2026-09-20."""
63
+ r = self.get(tweet_id)
64
+ res = r.get("data", "tweetResult", "result", default={}) or {}
65
+ leg = res.get("legacy", {}) or {}
66
+ views = None
67
+ try:
68
+ views = int((res.get("views") or {}).get("count"))
69
+ except (TypeError, ValueError):
70
+ pass
71
+ return {"views": views,
72
+ "likes": leg.get("favorite_count"),
73
+ "retweets": leg.get("retweet_count"),
74
+ "replies": leg.get("reply_count"),
75
+ "quotes": leg.get("quote_count"),
76
+ "bookmarks": leg.get("bookmark_count")}
77
+
78
+ def media(self, tweet_id):
79
+ """Attached media: [{type, url, thumb, width, height, duration_ms, bitrate, ...}].
80
+
81
+ type: photo | video | animated_gif. url = direct image url, or best mp4
82
+ (max bitrate) for video/gif. Empty list when no media.
83
+ Proven live by xapiweb 2026-09-20."""
84
+ r = self.get(tweet_id)
85
+ leg = r.get("data", "tweetResult", "result", "legacy", default={}) or {}
86
+ return parse_media_entities(leg)
87
+
88
+ def drafts(self):
89
+ """Draft tweets. Proven: test_draft_tweets.py"""
90
+ return self._s.gql_get("FetchDraftTweets", {"ascending": False},
91
+ with_features=False, with_toggles=False)
92
+
93
+ def scheduled(self):
94
+ """Scheduled tweets (read). Proven: test_scheduled_tweets.py"""
95
+ return self._s.gql_get("FetchScheduledTweets", {"ascending": True},
96
+ with_features=False, with_toggles=False)
97
+
98
+ # ---------------- write ----------------
99
+ def post(self, text, reply_to=None, media_ids=None, possibly_sensitive=False, verify=True):
100
+ """Post a tweet; optionally as a reply and/or with uploaded media.
101
+
102
+ reply_to: tweet id this is a reply to (proven live by xapiweb).
103
+ media_ids: list from media.upload_image() (proven live by xapiweb self-test).
104
+ Returns Response; new id at .get("data","create_tweet","tweet_results","result","rest_id").
105
+ No rest_id => NOT posted (bad txn) => raises XTxnError.
106
+ Proven: test_post_and_delete_tweet.py (+ xapiweb reply/media proofs).
107
+ """
108
+ media = {"media_entities": [], "possibly_sensitive": bool(possibly_sensitive)}
109
+ if media_ids:
110
+ media["media_entities"] = [{"media_id": str(m), "tagged_users": []} for m in media_ids]
111
+ variables = {"tweet_text": text, "media": media,
112
+ "semantic_annotation_ids": [], "disallowed_reply_options": None}
113
+ if reply_to:
114
+ variables["reply"] = {"in_reply_to_tweet_id": str(reply_to), "exclude_reply_user_ids": []}
115
+ resp = self._s.gql_post("CreateTweet", variables, with_features=True)
116
+ E.guard(resp, "CreateTweet")
117
+ nid = resp.get("data", "create_tweet", "tweet_results", "result", "rest_id")
118
+ if not nid:
119
+ raise E.XTxnError("CreateTweet 200 with empty tweet_results: bad/missing txn-id; NOT posted.")
120
+ resp.tweet_id = nid
121
+ if verify and reply_to:
122
+ back = self.get(nid)
123
+ got = back.get("data", "tweetResult", "result", "legacy", "in_reply_to_status_id_str")
124
+ if got != str(reply_to):
125
+ self.delete(nid, verify=False)
126
+ raise E.XVerifyError(f"Reply posted standalone (in_reply_to={got}); stray tweet deleted.")
127
+ return resp
128
+
129
+ def create_poll_card(self, choices, duration_minutes=1440):
130
+ """Create a poll card (caps API). choices: 2-4 labels (1-25 chars each).
131
+ duration_minutes: 5..10080. Returns card_uri like 'card://...'.
132
+ Proven live by xapiweb 2026-09-20 (2- and 4-choice, full post cycle)."""
133
+ import json as _json
134
+ import urllib.parse as _up
135
+ labels = list(choices)
136
+ if not (2 <= len(labels) <= 4):
137
+ raise ValueError("polls need 2-4 choices")
138
+ for c in labels:
139
+ if not c or len(c) > 25:
140
+ raise ValueError("poll choice labels must be 1-25 chars")
141
+ if not (5 <= int(duration_minutes) <= 10080):
142
+ raise ValueError("duration_minutes must be 5..10080")
143
+ n = len(labels)
144
+ card = {"twitter:card": f"poll{n}choice_text_only",
145
+ f"twitter:api:poll{n}choice_text_only": True,
146
+ "twitter:api:api:endpoint": "1",
147
+ "twitter:long:duration_minutes": str(int(duration_minutes))}
148
+ for i, c in enumerate(labels, 1):
149
+ card[f"twitter:string:choice{i}_label"] = c
150
+ r = self._s.call("POST", "https://caps.twitter.com/v2/cards/create",
151
+ _up.urlencode({"card_data": _json.dumps(card)}),
152
+ "application/x-www-form-urlencoded; charset=UTF-8")
153
+ uri = r.get("card_uri")
154
+ if r.status != 200 or not uri:
155
+ raise E.XError(f"Poll card create failed: {r.status} {r.raw[:200]}")
156
+ return uri
157
+
158
+ def post_poll(self, text, choices, duration_minutes=1440, verify=True):
159
+ """Post a tweet with an attached poll. Returns Response (id at .tweet_id,
160
+ card at .poll_card). Cards are single-use. Proven live by xapiweb 2026-09-20."""
161
+ uri = self.create_poll_card(choices, duration_minutes)
162
+ resp = self._s.gql_post("CreateTweet", {
163
+ "tweet_text": text, "card_uri": uri,
164
+ "media": {"media_entities": [], "possibly_sensitive": False},
165
+ "semantic_annotation_ids": [], "disallowed_reply_options": None},
166
+ with_features=True)
167
+ E.guard(resp, "CreateTweet")
168
+ nid = resp.get("data", "create_tweet", "tweet_results", "result", "rest_id")
169
+ if not nid:
170
+ raise E.XTxnError("CreateTweet 200 with empty tweet_results: bad txn; NOT posted.")
171
+ resp.tweet_id = nid
172
+ resp.poll_card = uri
173
+ if verify and "choice1_label" not in self.get(nid).raw:
174
+ raise E.XVerifyError(f"Poll card missing on re-read of {nid}.")
175
+ return resp
176
+
177
+ def post_thread(self, texts, verify=True):
178
+ """Post a thread (first tweet + chained replies). Returns [ids], in order.
179
+ On mid-thread failure, already-posted ids are attached as err.posted_ids
180
+ (delete them yourself). Proven live by xapiweb 2026-09-20."""
181
+ texts = list(texts)
182
+ if not texts:
183
+ raise ValueError("post_thread needs >= 1 text")
184
+ ids = []
185
+ try:
186
+ reply_to = None
187
+ for t in texts:
188
+ r = self.post(t, reply_to=reply_to, verify=verify)
189
+ reply_to = r.tweet_id
190
+ ids.append(reply_to)
191
+ except E.XError as e:
192
+ e.posted_ids = ids
193
+ raise
194
+ return ids
195
+
196
+ def delete(self, tweet_id, verify=True):
197
+ """Delete own tweet. Proven: test_post_and_delete_tweet.py"""
198
+ resp = self._s.gql_post("DeleteTweet", {"tweet_id": str(tweet_id)})
199
+ E.guard(resp, "DeleteTweet")
200
+ if verify:
201
+ back = self.get(tweet_id)
202
+ if '"tweetResult":{}' not in back.raw.replace(" ", ""):
203
+ raise E.XVerifyError(f"Tweet {tweet_id} still readable after delete.")
204
+ return resp
205
+
206
+ def pin(self, tweet_id):
207
+ """Pin own tweet. Proven: test_pin_cycle.py"""
208
+ resp = self._s.gql_post("PinTweet", {"tweet_id": str(tweet_id)})
209
+ return E.guard(resp, "PinTweet")
210
+
211
+ def unpin(self, tweet_id):
212
+ """Unpin own tweet. Proven: test_pin_cycle.py"""
213
+ resp = self._s.gql_post("UnpinTweet", {"tweet_id": str(tweet_id)})
214
+ return E.guard(resp, "UnpinTweet")
215
+
216
+ def set_reply_control(self, tweet_id, mode, allowed_country_codes=None, verify=True):
217
+ """Limit who can reply. Modes: ByInvitation|Verified|Subscribers|Community|
218
+ MyNetwork(gated)|CountryOfOrigin(+codes). Proven: test_conversation_control_pair.py"""
219
+ if mode not in REPLY_MODES:
220
+ raise ValueError(f"mode must be one of {REPLY_MODES}")
221
+ variables = {"tweet_id": str(tweet_id), "mode": mode}
222
+ if allowed_country_codes:
223
+ variables["allowed_country_codes"] = list(allowed_country_codes)
224
+ resp = self._s.gql_post("ConversationControlChange", variables)
225
+ E.guard(resp, "ConversationControlChange")
226
+ if verify:
227
+ back = self.get(tweet_id)
228
+ if f'"mode":"{mode}"' not in back.raw.replace(" ", ""):
229
+ raise E.XVerifyError(f"Reply-control marker {mode} not found on re-read.")
230
+ return resp
231
+
232
+ def remove_reply_control(self, tweet_id):
233
+ """Revert reply limits (idempotent 'Done'). Proven: test_conversation_control_pair.py"""
234
+ resp = self._s.gql_post("ConversationControlDelete", {"tweet_id": str(tweet_id)})
235
+ return E.guard(resp, "ConversationControlDelete")
236
+
237
+ def add_ad_disclosure(self, tweet_id, verify=True):
238
+ """Paid-promotion label. Proven: test_content_disclosure_pair.py"""
239
+ resp = self._s.gql_post("AddContentDisclosure", {
240
+ "tweet_id": str(tweet_id), "advertising_disclosure": {"is_paid_promotion": True}})
241
+ E.guard(resp, "AddContentDisclosure")
242
+ if verify:
243
+ self._assert_disclosure(tweet_id, True)
244
+ return resp
245
+
246
+ def add_ai_disclosure(self, tweet_id, verify=True):
247
+ """AI-generated-media label. Proven: test_content_disclosure_pair.py"""
248
+ resp = self._s.gql_post("AddContentDisclosure", {
249
+ "tweet_id": str(tweet_id),
250
+ "ai_generated_disclosure": {"has_ai_generated_media": True}})
251
+ E.guard(resp, "AddContentDisclosure")
252
+ if verify:
253
+ self._assert_disclosure(tweet_id, True)
254
+ return resp
255
+
256
+ def remove_disclosure(self, tweet_id, verify=True):
257
+ """Remove disclosure label. Proven: test_content_disclosure_pair.py"""
258
+ resp = self._s.gql_post("DeleteContentDisclosure", {"tweet_id": str(tweet_id)})
259
+ E.guard(resp, "DeleteContentDisclosure")
260
+ if verify:
261
+ import time
262
+ self._assert_disclosure(tweet_id, False)
263
+ return resp
264
+
265
+ def _assert_disclosure(self, tweet_id, present):
266
+ import time
267
+ d = self.detail(tweet_id)
268
+ has = "disclosure" in d.raw.lower()
269
+ if present and not has:
270
+ raise E.XVerifyError("Disclosure marker missing on re-read.")
271
+ if not present and has:
272
+ time.sleep(3)
273
+ d = self.detail(tweet_id)
274
+ if "disclosure" in d.raw.lower():
275
+ raise E.XVerifyError("Disclosure marker still present after remove.")
276
+
277
+ # ---------------- gated (documented, not usable on this account type) ----------------
278
+ def create_note(self, text):
279
+ """Long-form note. GATED: needs Premium (code 37 without). Proven: test_note_tweet_guard.py"""
280
+ return self._s.gql_post("CreateNoteTweet", {"tweet_text": text})
281
+
282
+ def create_highlight(self, tweet_id):
283
+ """GATED: needs unknown eligibility. Proven: test_create_highlight_shape.py"""
284
+ return self._s.gql_post("CreateHighlight", {"tweet_id": str(tweet_id)})
285
+
286
+ def delete_highlight(self, tweet_id):
287
+ """GATED: mirrors create. Proven: test_delete_highlight_shape.py"""
288
+ return self._s.gql_post("DeleteHighlight", {"tweet_id": str(tweet_id)})
@@ -0,0 +1,85 @@
1
+ """Users: profiles, batches, recommendations, credentials."""
2
+ from ._base import BaseResource
3
+
4
+
5
+ class Users(BaseResource):
6
+ def by_screen_name(self, screen_name, grok_bio=True):
7
+ """Proven: test_user_by_screen_name.py"""
8
+ return self._s.gql_get("UserByScreenName",
9
+ {"screen_name": screen_name, "withGrokTranslatedBio": grok_bio})
10
+
11
+ def by_id(self, user_id, grok_bio=True):
12
+ """Proven: test_user_by_rest_id.py"""
13
+ return self._s.gql_get("UserByRestId",
14
+ {"userId": str(user_id), "withGrokTranslatedBio": grok_bio})
15
+
16
+ def batch_by_ids(self, user_ids):
17
+ """POST-only (GET 404s). Proven: test_users_by_rest_ids.py"""
18
+ return self._s.gql_post("UsersByRestIds", {"userIds": [str(i) for i in user_ids]})
19
+
20
+ def batch_by_names(self, screen_names):
21
+ """snake_case var! Proven: test_users_by_screen_names.py"""
22
+ return self._s.gql_get("UsersByScreenNames", {"screen_names": list(screen_names)})
23
+
24
+ def viewer(self):
25
+ """Viewer/me object. Proven: test_viewer.py"""
26
+ return self._s.gql_get("Viewer", {})
27
+
28
+ def username_availability(self, username, suggestions=True):
29
+ """Proven: test_username_availability.py"""
30
+ return self._s.gql_post("GetUsernameAvailabilityAndSuggestions", {
31
+ "username": username, "include_suggestions": suggestions, "session_token": ""})
32
+
33
+ def show(self, user_id):
34
+ """REST, txn-gated (404 without). Proven: test_user_show.py"""
35
+ return self._s.rest_get("users/show.json", {"user_id": str(user_id), "skip_status": 1})
36
+
37
+ def lookup(self, user_ids):
38
+ """REST batch, txn-gated. Proven: test_users_lookup.py"""
39
+ if isinstance(user_ids, (list, tuple)):
40
+ user_ids = ",".join(str(i) for i in user_ids)
41
+ return self._s.rest_get("users/lookup.json", {"user_id": user_ids})
42
+
43
+ def verify_credentials(self):
44
+ """REST, txn-gated. Proven: test_verify_credentials.py"""
45
+ return self._s.rest_get("account/verify_credentials.json", {"skip_status": 1})
46
+
47
+ def recommendations(self, limit=3, user_id=None):
48
+ """Proven: test_user_recommendations.py"""
49
+ return self._s.rest_get("users/recommendations.json", {
50
+ "limit": limit, "user_id": str(user_id or self._me()),
51
+ "display_location": "profile-cluster-follow", "skip_status": 1})
52
+
53
+ def verified_avatars(self, user_ids):
54
+ """Proven: test_users_verified_avatars.py"""
55
+ return self._s.gql_get("UsersVerifiedAvatars",
56
+ {"userIds": [str(i) for i in user_ids]}, with_toggles=False)
57
+
58
+ def spotlights(self, screen_name):
59
+ """Proven: test_profile_spotlights.py"""
60
+ return self._s.gql_get("ProfileSpotlightsQuery", {"screen_name": screen_name},
61
+ with_features=False, with_toggles=False)
62
+
63
+ def claims(self):
64
+ """Proven: test_user_claims.py"""
65
+ return self._s.gql_get("GetUserClaims", {})
66
+
67
+ def preferences(self):
68
+ """Proven: test_user_preferences.py"""
69
+ return self._s.gql_get("UserPreferences", {})
70
+
71
+ def sessions(self):
72
+ """Active sessions list. Proven: test_user_sessions.py"""
73
+ return self._s.gql_get("UserSessionsList", {})
74
+
75
+ def upsells(self):
76
+ """Proven: test_upsells.py"""
77
+ return self._s.gql_get("Upsells", {})
78
+
79
+ def me(self):
80
+ """Cached {id, screen_name, ...} of the session owner (via verify_credentials)."""
81
+ if self._client._me_cache is None:
82
+ r = self.verify_credentials()
83
+ self._client._me_cache = {"id": r.get("id_str"), "screen_name": r.get("screen_name"),
84
+ "name": r.get("name"), "raw": r}
85
+ return self._client._me_cache
xapiweb/response.py ADDED
@@ -0,0 +1,39 @@
1
+ """Response: one parsed wrapper for every xapiweb call."""
2
+ import json
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class Response:
8
+ url: str
9
+ status: int
10
+ headers: dict
11
+ raw: str
12
+ _data: object = field(default=None, repr=False, compare=False)
13
+
14
+ @property
15
+ def data(self):
16
+ if self._data is None:
17
+ try:
18
+ self._data = json.loads(self.raw)
19
+ except Exception:
20
+ self._data = {}
21
+ return self._data
22
+
23
+ @property
24
+ def ok(self):
25
+ return 200 <= self.status < 300
26
+
27
+ def get(self, *keys, default=None):
28
+ """Safe nested walk over parsed JSON (dict keys or list indices)."""
29
+ cur = self.data
30
+ try:
31
+ for k in keys:
32
+ cur = cur[k]
33
+ return cur
34
+ except Exception:
35
+ return default
36
+
37
+ def summary(self, limit=600):
38
+ body = self.raw if isinstance(self.raw, str) else json.dumps(self.raw)
39
+ return body[:limit] + ("…(truncated)" if len(body) > limit else "")
xapiweb/session.py ADDED
@@ -0,0 +1,213 @@
1
+ """Session: auth, txn-id, pacing, and low-level HTTP for X web APIs.
2
+
3
+ Engine ported from the proven x_api_pack (common.py, live 2026-09-19),
4
+ repackaged as a class so secrets are never module-global.
5
+ Stdlib only.
6
+ """
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+
15
+ from ._txn import ClientTransaction
16
+ from .qids import QIDS
17
+ from .response import Response
18
+ from . import errors as E
19
+
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ DATA_DIR = os.path.join(HERE, "data")
22
+ DEFAULT_BEARER = ("AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D"
23
+ "1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA")
24
+ DEFAULT_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:154.0) "
25
+ "Gecko/20100101 Firefox/154.0")
26
+ TXN_TTL = 600 # one homepage load serves many calls, like a browser tab
27
+
28
+
29
+ class Session:
30
+ def __init__(self, cookie, csrf_token, bearer=None, user_agent=None,
31
+ txn_cache=None, min_interval=1.0, timeout=25):
32
+ if not cookie or not csrf_token:
33
+ raise E.XError("cookie and csrf_token are required")
34
+ self.cookie = cookie
35
+ self.csrf_token = csrf_token
36
+ self.bearer = bearer or DEFAULT_BEARER
37
+ self.user_agent = user_agent or DEFAULT_UA
38
+ self.txn_cache = txn_cache or os.path.join(tempfile.gettempdir(), "xapiweb_txn_cache.json")
39
+ self.min_interval = min_interval
40
+ self.timeout = timeout
41
+ with open(os.path.join(DATA_DIR, "features.json")) as f:
42
+ self.features = json.load(f)
43
+ with open(os.path.join(DATA_DIR, "fieldtoggles.json")) as f:
44
+ self.field_toggles = json.load(f)
45
+ self._txn = None
46
+ self._last_call = 0.0
47
+ self.refs = {} # reference_ids from session file (self_id, ...)
48
+ self.account = None
49
+
50
+ @classmethod
51
+ def from_session_file(cls, path, **kw):
52
+ """Load a my_data.json-style session file (cookie + csrf_token + ...)."""
53
+ with open(path) as f:
54
+ d = json.load(f)
55
+ s = cls(
56
+ d["cookie"], d.get("csrf_token") or d.get("ct0"),
57
+ bearer=d.get("bearer"), user_agent=d.get("user_agent"),
58
+ txn_cache=kw.pop("txn_cache", os.path.join(
59
+ os.path.dirname(os.path.abspath(path)), ".txn_cache.json")),
60
+ **kw)
61
+ s.refs = d.get("reference_ids", {}) or {}
62
+ s.account = d.get("account")
63
+ return s
64
+
65
+ # ---------------- transaction id ----------------
66
+ def _key_works(self, html):
67
+ """True if txn-ids minted from this homepage HTML are accepted (cheap probe)."""
68
+ try:
69
+ t = ClientTransaction()
70
+ t.init_from_html(html)
71
+ tid = t.generate_transaction_id(method="GET", path="/1.1/account/settings.json")
72
+ except Exception:
73
+ return False
74
+ hh = {"Authorization": f"Bearer {self.bearer}", "x-twitter-auth-type": "OAuth2Session",
75
+ "x-csrf-token": self.csrf_token, "x-twitter-client-language": "en",
76
+ "x-twitter-active-user": "yes", "x-client-transaction-id": tid,
77
+ "User-Agent": self.user_agent, "Accept": "*/*", "Referer": "https://x.com/home",
78
+ "Cookie": self.cookie}
79
+ try:
80
+ req = urllib.request.Request("https://api.x.com/1.1/account/settings.json",
81
+ headers=hh, method="GET")
82
+ with urllib.request.urlopen(req, timeout=20) as r:
83
+ return r.status == 200
84
+ except Exception:
85
+ return False
86
+
87
+ def _txn_html(self):
88
+ """Logged-in homepage HTML with a WORKING key, cached on disk."""
89
+ try:
90
+ d = json.load(open(self.txn_cache))
91
+ html = d.get("html", "")
92
+ if time.time() - d.get("saved_at", 0) < TXN_TTL and self._key_works(html):
93
+ return html
94
+ except Exception:
95
+ pass
96
+ for _ in range(3):
97
+ req = urllib.request.Request("https://x.com/home",
98
+ headers={"User-Agent": self.user_agent,
99
+ "Cookie": self.cookie, "Accept": "text/html"})
100
+ html = urllib.request.urlopen(req, timeout=25).read().decode("utf-8", "replace")
101
+ if "loading-x-anim" in html and "twitter-site-verification" in html \
102
+ and self._key_works(html):
103
+ try:
104
+ json.dump({"saved_at": time.time(), "html": html}, open(self.txn_cache, "w"))
105
+ except Exception:
106
+ pass
107
+ return html
108
+ time.sleep(2)
109
+ raise RuntimeError("3 homepage loads yielded no working transaction key")
110
+
111
+ def txn_id(self, method: str, path: str) -> str:
112
+ """Valid x-client-transaction-id for one (method, path)."""
113
+ if self._txn is None:
114
+ self._txn = ClientTransaction()
115
+ self._txn.init_from_html(self._txn_html())
116
+ return self._txn.generate_transaction_id(method=method, path=path)
117
+
118
+ def headers(self, method: str, path: str, referer="https://x.com/home"):
119
+ return {
120
+ "Authorization": f"Bearer {self.bearer}",
121
+ "x-twitter-auth-type": "OAuth2Session",
122
+ "x-csrf-token": self.csrf_token,
123
+ "x-twitter-client-language": "en",
124
+ "x-twitter-active-user": "yes",
125
+ "x-client-transaction-id": self.txn_id(method, path),
126
+ "User-Agent": self.user_agent, "Accept": "*/*",
127
+ "Referer": referer, "Cookie": self.cookie,
128
+ }
129
+
130
+ # ---------------- HTTP ----------------
131
+ def _pace(self):
132
+ wait = self.min_interval - (time.monotonic() - self._last_call)
133
+ if wait > 0:
134
+ time.sleep(wait)
135
+
136
+ def call(self, method, url, payload=None, content_type="application/json",
137
+ referer="https://x.com/home", timeout=None) -> Response:
138
+ """One HTTP call with pacing + one retry on 403/429. Raises structured errors."""
139
+ self._pace()
140
+ path = urllib.parse.urlparse(url).path
141
+ hh = self.headers(method, path, referer)
142
+ data = None
143
+ if payload is not None:
144
+ data = payload.encode() if isinstance(payload, str) else payload
145
+ hh["Content-Type"] = content_type
146
+ hh["Origin"] = "https://x.com"
147
+ hh["Content-Length"] = str(len(data))
148
+ req = urllib.request.Request(url, data=data, headers=hh, method=method)
149
+ to = timeout or self.timeout
150
+ try:
151
+ for attempt in (0, 1):
152
+ try:
153
+ with urllib.request.urlopen(req, timeout=to) as resp:
154
+ self._last_call = time.monotonic()
155
+ return Response(url=url, status=resp.status,
156
+ headers=dict(resp.headers),
157
+ raw=resp.read().decode("utf-8", "replace"))
158
+ except urllib.error.HTTPError as e:
159
+ if e.code in (403, 429) and attempt == 0:
160
+ time.sleep(5)
161
+ continue
162
+ raise
163
+ except urllib.error.HTTPError as e:
164
+ self._last_call = time.monotonic()
165
+ body = ""
166
+ try:
167
+ body = e.read().decode("utf-8", "replace")
168
+ except Exception:
169
+ pass
170
+ raise E.http_error(url, e.code, body)
171
+
172
+ # ---------------- GraphQL / REST helpers ----------------
173
+ def gql_get(self, op, variables, with_features=True, with_toggles=True) -> Response:
174
+ qid = QIDS[op]
175
+ qs = {"variables": json.dumps(variables, separators=(",", ":"))}
176
+ if with_features:
177
+ qs["features"] = json.dumps(self.features, separators=(",", ":"))
178
+ if with_toggles:
179
+ qs["fieldToggles"] = json.dumps(self.field_toggles, separators=(",", ":"))
180
+ url = f"https://x.com/i/api/graphql/{qid}/{op}?" + urllib.parse.urlencode(qs)
181
+ return self.call("GET", url)
182
+
183
+ def gql_post(self, op, variables, with_features=False) -> Response:
184
+ qid = QIDS[op]
185
+ url = f"https://x.com/i/api/graphql/{qid}/{op}"
186
+ payload = {"variables": variables, "queryId": qid}
187
+ if with_features:
188
+ payload["features"] = self.features
189
+ return self.call("POST", url, json.dumps(payload))
190
+
191
+ def rest_get(self, path, params=None, host="https://x.com", prefix="/i/api/1.1/") -> Response:
192
+ url = host + prefix + path.lstrip("/") + ("?" + urllib.parse.urlencode(params) if params else "")
193
+ return self.call("GET", url)
194
+
195
+ def rest_post_form(self, path, form, host="https://x.com", prefix="/i/api/1.1/") -> Response:
196
+ url = host + prefix + path.lstrip("/")
197
+ return self.call("POST", url, urllib.parse.urlencode(form),
198
+ "application/x-www-form-urlencoded; charset=UTF-8")
199
+
200
+ @staticmethod
201
+ def jget(body, *keys, default=None):
202
+ try:
203
+ cur = json.loads(body) if isinstance(body, str) else body
204
+ for k in keys:
205
+ cur = cur[k]
206
+ return cur
207
+ except Exception:
208
+ return default
209
+
210
+ @staticmethod
211
+ def summarize(body, limit=600):
212
+ body = body if isinstance(body, str) else json.dumps(body)
213
+ return body[:limit] + ("…(truncated)" if len(body) > limit else "")