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.
- xapiweb/__init__.py +13 -0
- xapiweb/_txn.py +224 -0
- xapiweb/client.py +52 -0
- xapiweb/errors.py +114 -0
- xapiweb/qids.py +126 -0
- xapiweb/resources/__init__.py +20 -0
- xapiweb/resources/_base.py +13 -0
- xapiweb/resources/communities.py +34 -0
- xapiweb/resources/dms.py +48 -0
- xapiweb/resources/engagement.py +83 -0
- xapiweb/resources/follows.py +115 -0
- xapiweb/resources/lists.py +75 -0
- xapiweb/resources/media.py +150 -0
- xapiweb/resources/misc.py +164 -0
- xapiweb/resources/moderation.py +84 -0
- xapiweb/resources/notifications.py +84 -0
- xapiweb/resources/settings.py +102 -0
- xapiweb/resources/timelines.py +228 -0
- xapiweb/resources/trends.py +49 -0
- xapiweb/resources/tweets.py +288 -0
- xapiweb/resources/users.py +85 -0
- xapiweb/response.py +39 -0
- xapiweb/session.py +213 -0
- xapiweb/tests/test_offline.py +162 -0
- xapiweb-1.5.0.dist-info/METADATA +5 -0
- xapiweb-1.5.0.dist-info/RECORD +28 -0
- xapiweb-1.5.0.dist-info/WHEEL +5 -0
- xapiweb-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -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 "")
|