xapiweb 1.5.0__tar.gz

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-1.5.0/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: xapiweb
3
+ Version: 1.5.0
4
+ Summary: Unified Python library for the X web API (stdlib only, 157 proven endpoints)
5
+ Requires-Python: >=3.8
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "xapiweb"
3
+ version = "1.5.0"
4
+ description = "Unified Python library for the X web API (stdlib only, 157 proven endpoints)"
5
+ requires-python = ">=3.8"
6
+ dependencies = []
7
+
8
+ [build-system]
9
+ requires = ["setuptools"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """xapiweb — unified Python library for the X web API (stdlib only).
2
+
3
+ Built from the proven x_api_pack (157/157 tests live 2026-09-19):
4
+ every method maps to a live-tested endpoint; writes verify by re-reading.
5
+ """
6
+ from .client import XClient
7
+ from .session import Session
8
+ from .response import Response
9
+ from . import errors
10
+ from .qids import QIDS
11
+
12
+ __version__ = "1.5.0"
13
+ __all__ = ["XClient", "Session", "Response", "errors", "QIDS"]
@@ -0,0 +1,224 @@
1
+ """x-client-transaction-id generator (stdlib only, no third-party deps).
2
+
3
+ Generates the per-request anti-bot token X's web client attaches to API calls.
4
+ Valid tokens REQUIRE the key + animation frames from the *logged-in* homepage
5
+ (https://x.com/home fetched with session cookies) - the logged-out page yields
6
+ tokens the server rejects.
7
+
8
+ Algorithm ported from twikit's twikit/x_client_transaction (MIT, by twikit
9
+ contributors) with BeautifulSoup replaced by regex + urllib so this file has
10
+ zero dependencies. Tested live 2026-09-19: tokens accepted (200) on
11
+ txn-gated endpoints (CreateTweet, users/show, account/settings, ...).
12
+
13
+ Key-byte indices [47, 41, 13, 12] were extracted 2026-09-19 from
14
+ abs.twimg.com/.../ondemand.s.0cc29d94ce9e97eea.js with the regex below.
15
+ If X rotates the deploy and tokens start failing, re-derive:
16
+ 1. DevTools Network tab -> find ondemand.s.<hash>a.js -> download it
17
+ 2. indices = re.findall(r"\\(\\w\\[(\\d{1,2})\\],\\s*16\\)", src)
18
+ 3. row_index, key_indices = indices[0], indices[1:]
19
+ """
20
+ import base64
21
+ import hashlib
22
+ import math
23
+ import random
24
+ import re
25
+ import time
26
+ import urllib.request
27
+ from functools import reduce
28
+ from typing import List, Union
29
+
30
+ INDICES_REGEX = re.compile(r"(\(\w{1}\[(\d{1,2})\],\s*16\))+")
31
+
32
+ # ---------------------------------------------------------------- cubic ---
33
+ class Cubic:
34
+ def __init__(self, curves: List[Union[float, int]]):
35
+ self.curves = curves
36
+
37
+ def get_value(self, time_: Union[float, int]):
38
+ start_gradient = end_gradient = start = mid = 0.0
39
+ end = 1.0
40
+ if time_ <= 0.0:
41
+ if self.curves[0] > 0.0:
42
+ start_gradient = self.curves[1] / self.curves[0]
43
+ elif self.curves[1] == 0.0 and self.curves[2] > 0.0:
44
+ start_gradient = self.curves[3] / self.curves[2]
45
+ return start_gradient * time_
46
+ if time_ >= 1.0:
47
+ if self.curves[2] < 1.0:
48
+ end_gradient = (self.curves[3] - 1.0) / (self.curves[2] - 1.0)
49
+ elif self.curves[2] == 1.0 and self.curves[0] < 1.0:
50
+ end_gradient = (self.curves[1] - 1.0) / (self.curves[0] - 1.0)
51
+ return 1.0 + end_gradient * (time_ - 1.0)
52
+ while start < end:
53
+ mid = (start + end) / 2
54
+ x_est = self.calculate(self.curves[0], self.curves[2], mid)
55
+ if abs(time_ - x_est) < 0.00001:
56
+ return self.calculate(self.curves[1], self.curves[3], mid)
57
+ if x_est < time_:
58
+ start = mid
59
+ else:
60
+ end = mid
61
+ return self.calculate(self.curves[1], self.curves[3], mid)
62
+
63
+ @staticmethod
64
+ def calculate(a, b, m):
65
+ return 3.0 * a * (1 - m) * (1 - m) * m + 3.0 * b * (1 - m) * m * m + m * m * m
66
+
67
+
68
+ # ---------------------------------------------------------- interpolate ---
69
+ def _interpolate_num(from_val, to_val, f):
70
+ if all(isinstance(n, (int, float)) for n in (from_val, to_val)):
71
+ return from_val * (1 - f) + to_val * f
72
+ if all(isinstance(n, bool) for n in (from_val, to_val)):
73
+ return from_val if f < 0.5 else to_val
74
+
75
+
76
+ def interpolate(from_list, to_list, f):
77
+ if len(from_list) != len(to_list):
78
+ raise Exception(f"Mismatched interpolation arguments {from_list}: {to_list}")
79
+ return [_interpolate_num(a, b, f) for a, b in zip(from_list, to_list)]
80
+
81
+
82
+ # ------------------------------------------------------------- rotation ---
83
+ def convert_rotation_to_matrix(rotation: Union[float, int]):
84
+ rad = math.radians(rotation)
85
+ return [math.cos(rad), -math.sin(rad), math.sin(rad), math.cos(rad)]
86
+
87
+
88
+ # ---------------------------------------------------------------- utils ---
89
+ def float_to_hex(x):
90
+ result = []
91
+ quotient = int(x)
92
+ fraction = x - quotient
93
+ while quotient > 0:
94
+ quotient = int(x / 16)
95
+ remainder = int(x - (float(quotient) * 16))
96
+ result.insert(0, chr(remainder + 55) if remainder > 9 else str(remainder))
97
+ x = float(quotient)
98
+ if fraction == 0:
99
+ return ''.join(result)
100
+ result.append('.')
101
+ while fraction > 0:
102
+ fraction *= 16
103
+ integer = int(fraction)
104
+ fraction -= float(integer)
105
+ result.append(chr(integer + 55) if integer > 9 else str(integer))
106
+ return ''.join(result)
107
+
108
+
109
+ def is_odd(num: Union[int, float]):
110
+ return -1.0 if num % 2 else 0.0
111
+
112
+
113
+ def base64_encode(data) -> str:
114
+ data = data.encode() if isinstance(data, str) else data
115
+ return base64.b64encode(data).decode()
116
+
117
+
118
+ # ---------------------------------------------------- transaction (sync) ---
119
+ class ClientTransaction:
120
+ ADDITIONAL_RANDOM_NUMBER = 3
121
+ DEFAULT_KEYWORD = "obfiowerehiring"
122
+
123
+ def __init__(self, row_index: int = 47, key_indices=(41, 13, 12)):
124
+ self.home_html = None
125
+ self.frames_d = []
126
+ self.key = None
127
+ self.key_bytes = None
128
+ self.animation_key = None
129
+ self.row_index = row_index
130
+ self.key_indices = list(key_indices)
131
+
132
+ # -- stdlib replacements for the BeautifulSoup parts -------------------
133
+ @staticmethod
134
+ def extract_key(html: str) -> str:
135
+ m = re.search(r'<meta[^>]*twitter-site-verification[^>]*content="([^"]+)"', html)
136
+ if not m:
137
+ m = re.search(r'<meta[^>]*content="([^"]+)"[^>]*twitter-site-verification', html)
138
+ if not m:
139
+ raise Exception("Couldn't get key from the page source")
140
+ return m.group(1)
141
+
142
+ @staticmethod
143
+ def extract_frames(html: str) -> List[str]:
144
+ """Return the animation-path `d` attr (2nd <path>) of each loading-x-anim svg."""
145
+ ds = []
146
+ for i in range(4):
147
+ m = re.search(r'id="loading-x-anim-%d".*?</svg>' % i, html, re.S)
148
+ if not m:
149
+ raise Exception(f"loading-x-anim-{i} not found in page")
150
+ paths = re.findall(r'<path[^>]*\sd="([^"]+)"', m.group(0))
151
+ if len(paths) < 2:
152
+ raise Exception(f"animation path missing in frame {i}")
153
+ ds.append(paths[1])
154
+ return ds
155
+
156
+ def init_from_html(self, html: str):
157
+ self.home_html = html
158
+ self.key = self.extract_key(html)
159
+ self.key_bytes = list(base64.b64decode(bytes(self.key, "utf-8")))
160
+ self.frames_d = self.extract_frames(html)
161
+ self.animation_key = self.get_animation_key(self.key_bytes)
162
+
163
+ def init_logged_in(self, cookie: str, user_agent: str):
164
+ req = urllib.request.Request(
165
+ "https://x.com/home", headers={"User-Agent": user_agent, "Cookie": cookie})
166
+ html = urllib.request.urlopen(req, timeout=25).read().decode("utf-8", "replace")
167
+ self.init_from_html(html)
168
+
169
+ # -- math (ported verbatim) --------------------------------------------
170
+ def get_2d_array(self, key_bytes):
171
+ d = self.frames_d[key_bytes[5] % 4]
172
+ return [[int(x) for x in re.sub(r"[^\d]+", " ", item).strip().split()]
173
+ for item in d[9:].split("C")]
174
+
175
+ def solve(self, value, min_val, max_val, rounding: bool):
176
+ result = value * (max_val - min_val) / 255 + min_val
177
+ return math.floor(result) if rounding else round(result, 2)
178
+
179
+ def animate(self, frames, target_time):
180
+ from_color = [float(i) for i in [*frames[:3], 1]]
181
+ to_color = [float(i) for i in [*frames[3:6], 1]]
182
+ from_rotation = [0.0]
183
+ to_rotation = [self.solve(float(frames[6]), 60.0, 360.0, True)]
184
+ frames = frames[7:]
185
+ curves = [self.solve(float(item), is_odd(c), 1.0, False)
186
+ for c, item in enumerate(frames)]
187
+ val = Cubic(curves).get_value(target_time)
188
+ color = [v if v > 0 else 0 for v in interpolate(from_color, to_color, val)]
189
+ rotation = interpolate(from_rotation, to_rotation, val)
190
+ matrix = convert_rotation_to_matrix(rotation[0])
191
+ str_arr = [format(round(v), "x") for v in color[:-1]]
192
+ for value in matrix:
193
+ rounded = round(value, 2)
194
+ if rounded < 0:
195
+ rounded = -rounded
196
+ hv = float_to_hex(rounded)
197
+ str_arr.append(f"0{hv}".lower() if hv.startswith(".") else hv if hv else "0")
198
+ str_arr.extend(["0", "0"])
199
+ return re.sub(r"[.-]", "", "".join(str_arr))
200
+
201
+ def get_animation_key(self, key_bytes):
202
+ row_index = key_bytes[self.row_index] % 16
203
+ frame_time = reduce(lambda a, b: a * b,
204
+ [key_bytes[i] % 16 for i in self.key_indices])
205
+ frame_row = self.get_2d_array(key_bytes)[row_index]
206
+ return self.animate(frame_row, float(frame_time) / 4096)
207
+
208
+ def generate_transaction_id(self, method: str, path: str, time_now=None) -> str:
209
+ time_now = time_now or math.floor((time.time() * 1000 - 1682924400 * 1000) / 1000)
210
+ time_now_bytes = [(time_now >> (i * 8)) & 0xFF for i in range(4)]
211
+ key_bytes = self.key_bytes or list(base64.b64decode(bytes(self.key, "utf-8")))
212
+ animation_key = self.animation_key or self.get_animation_key(key_bytes)
213
+ digest = hashlib.sha256(
214
+ f"{method}!{path}!{time_now}{self.DEFAULT_KEYWORD}{animation_key}".encode()
215
+ ).digest()
216
+ random_num = random.randint(0, 255)
217
+ arr = [*key_bytes, *time_now_bytes, *list(digest)[:16],
218
+ self.ADDITIONAL_RANDOM_NUMBER]
219
+ out = bytearray([random_num, *[b ^ random_num for b in arr]])
220
+ return base64_encode(out).strip("=")
221
+
222
+
223
+ if __name__ == "__main__":
224
+ print("import this module; see common.py for usage")
@@ -0,0 +1,52 @@
1
+ """XClient: one object for the whole X web API (stdlib only)."""
2
+ from .session import Session
3
+ from .resources import (Tweets, Engagement, Timelines, Users, Follows, Moderation,
4
+ Lists, DMs, Communities, Trends, Notifications, Settings, Media, Misc)
5
+
6
+
7
+ class XClient:
8
+ """Unified client. Writes verify by re-reading; calls are paced (>=1s apart).
9
+
10
+ Usage:
11
+ from xapiweb import XClient
12
+ x = XClient.from_session_file("my_data.json") # cookie + csrf_token
13
+ x.timelines.home()
14
+ x.tweets.post("hello", verify=True)
15
+ """
16
+
17
+ def __init__(self, cookie=None, csrf_token=None, session=None, **session_kw):
18
+ self.session = session or Session(cookie, csrf_token, **session_kw)
19
+ self._me_cache = None
20
+ self.tweets = Tweets(self)
21
+ self.engagement = Engagement(self)
22
+ self.timelines = Timelines(self)
23
+ self.users = Users(self)
24
+ self.follows = Follows(self)
25
+ self.moderation = Moderation(self)
26
+ self.lists = Lists(self)
27
+ self.dms = DMs(self)
28
+ self.communities = Communities(self)
29
+ self.trends = Trends(self)
30
+ self.notifications = Notifications(self)
31
+ self.settings = Settings(self)
32
+ self.media = Media(self)
33
+ self.misc = Misc(self)
34
+
35
+ @classmethod
36
+ def from_session_file(cls, path, **session_kw):
37
+ return cls(session=Session.from_session_file(path, **session_kw))
38
+
39
+ @property
40
+ def me_id(self):
41
+ """Session owner's user id (from session file refs, else fetched+cached)."""
42
+ if self.session.refs.get("self_id"):
43
+ return self.session.refs["self_id"]
44
+ return self.me()["id"]
45
+
46
+ def me(self):
47
+ """Cached {id, screen_name, name} of the session owner."""
48
+ return self.users.me()
49
+
50
+ def health(self):
51
+ """Cheap liveness probe (badge counts). 200 => session alive."""
52
+ return self.notifications.badge_counts()
@@ -0,0 +1,114 @@
1
+ """Structured errors for xapiweb (mapped from the pack's error catalog, guide §9)."""
2
+
3
+
4
+ class XError(Exception):
5
+ """Base error for all xapiweb failures."""
6
+
7
+
8
+ class XHttpError(XError):
9
+ """HTTP-level failure."""
10
+
11
+ def __init__(self, url, status, body=""):
12
+ self.url = url
13
+ self.status = status
14
+ self.body = body or ""
15
+ super().__init__(f"HTTP {status} {url}: {self.body[:200]}")
16
+
17
+
18
+ class XAuthError(XHttpError):
19
+ """401 — session dead (recapture cookies + ct0)."""
20
+
21
+
22
+ class XRateLimitError(XHttpError):
23
+ """403/429 — throttle/challenge (already retried once; cool off)."""
24
+
25
+
26
+ class XTxnError(XError):
27
+ """Bad/missing x-client-transaction-id masquerading as another error
28
+ (REST 404 code 34, CreateTweet 200 with empty tweet_results, CreateBookmark 404,
29
+ or 344 'daily limit' MISTEXT on a fresh account)."""
30
+
31
+
32
+ class XValidationError(XHttpError):
33
+ """422 GRAPHQL_VALIDATION_FAILED — missing/wrong variable (see body path)."""
34
+
35
+
36
+ class XMethodError(XHttpError):
37
+ """406 — mutation called via GET (use POST)."""
38
+
39
+
40
+ class XApiError(XError):
41
+ """GraphQL data-level error (HTTP 200 with errors[])."""
42
+
43
+ def __init__(self, code, message, op=""):
44
+ self.code = code
45
+ self.message = message or ""
46
+ self.op = op
47
+ super().__init__(f"[{op}] code {code}: {self.message[:250]}" if op else f"code {code}: {self.message[:250]}")
48
+
49
+
50
+ class XAutomationFlag(XApiError):
51
+ """226 — 'looks like it might be automated' (slow down, cool off)."""
52
+
53
+
54
+ class XGateError(XApiError):
55
+ """37 — Authorization: not author / no phone / no Premium / no eligibility."""
56
+
57
+
58
+ class XBadRequest(XApiError):
59
+ """214 — BadRequest: bad token / invalid target."""
60
+
61
+
62
+ class XAlreadyError(XApiError):
63
+ """327 — already retweeted (unretweet first)."""
64
+
65
+
66
+ class XDailyLimit(XApiError):
67
+ """344 — daily limit (often MISTEXT when txn is bad — fix txn first)."""
68
+
69
+
70
+ class XVerifyError(XError):
71
+ """Verify-by-reread failed: the ack lied or state did not change."""
72
+
73
+
74
+ def http_error(url, status, body):
75
+ """Map an HTTP failure to a structured exception (guide §9)."""
76
+ body = body or ""
77
+ compact = body.replace(" ", "")
78
+ if status == 401:
79
+ return XAuthError(url, status, body)
80
+ if status in (403, 429):
81
+ return XRateLimitError(url, status, body)
82
+ if status == 406:
83
+ return XMethodError(url, status, body)
84
+ if status == 422:
85
+ return XValidationError(url, status, body)
86
+ if status == 404 and '"code":34' in compact:
87
+ return XTxnError(
88
+ f"404 code 34 on {url}: almost certainly a bad/missing "
89
+ f"x-client-transaction-id (not a dead endpoint). Regenerate txn for "
90
+ f"the exact (method, path); probe account/settings (must 200)."
91
+ )
92
+ return XHttpError(url, status, body)
93
+
94
+
95
+ def guard(resp, op=""):
96
+ """Raise on GraphQL data-level errors[]; return resp otherwise."""
97
+ data = resp.data
98
+ errs = data.get("errors") if isinstance(data, dict) else None
99
+ if not errs:
100
+ return resp
101
+ e0 = errs[0] if isinstance(errs, list) else errs
102
+ code = e0.get("code") if isinstance(e0, dict) else None
103
+ msg = (e0.get("message", "") if isinstance(e0, dict) else str(e0)) or ""
104
+ if code == 226:
105
+ raise XAutomationFlag(code, msg, op)
106
+ if code == 37:
107
+ raise XGateError(code, msg, op)
108
+ if code == 214:
109
+ raise XBadRequest(code, msg, op)
110
+ if code == 327:
111
+ raise XAlreadyError(code, msg, op)
112
+ if code == 344:
113
+ raise XDailyLimit(code, msg, op)
114
+ raise XApiError(code, msg, op)
@@ -0,0 +1,126 @@
1
+ """Operation -> queryId table (all values verified live 2026-09-19)."""
2
+
3
+ QIDS = {
4
+ # ledger reads (GET)
5
+ "Following": "-Mn4uN7C-vxXBwUKtSwS6A", "PinnedTimelines": "1C9qXYjxcujNpyJWE6tAeg",
6
+ "CreatorStudioTabBarItemQuery": "1KZj_GRTxmPaSrk8jIb1Yw",
7
+ "isEligibleForVoButtonUpsellQuery": "BuWF9hiwmUyFdGo3J4DqbA",
8
+ "BlueVerifiedFollowers": "DWeIe6l1rsZMqHPbbWXtig",
9
+ "FetchScheduledTweets": "H2elmT2R9DLhWoo0DZFNkA",
10
+ "useStoryTopicQuery": "I3V_Tt32aTZdw7cBdKUJbg",
11
+ "ProfileTeamRoster": "I5ALHyuvPb6h7soVmbcq9g",
12
+ "NflScoresSidebarFollow": "JR_EpH80gwgFyOBOreLBZw", "UserByScreenName": "KybxDj9RrADIITXlGG8kpw",
13
+ "NotificationsTimeline": "MSUPE4KwuxyghDO60Bv5uQ", # user-captured 2026-09-20
14
+ "FetchDraftTweets": "L9RqKWmAWxK6vGtR3Qdsxw",
15
+ "QuickPromoteEligibility": "LtpCXh66W-uXh7u7XSRA8Q",
16
+ "ProfileSeasonSchedule": "M946jrb9CchmjVYMukJ6RQ",
17
+ "UsersVerifiedAvatars": "O9O_O1iF0QffoOBjB2OGIQ",
18
+ "getAltTextPromptPreference": "PFIxTk8owMoZgiMccP0r4g",
19
+ "SidebarUserRecommendations": "XFGj2pdlasiM3FndUe1PrQ",
20
+ "UserOriginalsTimeline": "Yr8749ieoUptxRqQv766Fw",
21
+ "Followers": "fVGYs5W9kNUuoUrZwYZQpQ", # POST only (GET 404s)
22
+ "ProfileSpotlightsQuery": "mzoqrVGwk-YTSGME1dRfXQ",
23
+ "ViewerBadgeCounts": "q4Npr1-FYRWyXsRzPckwEA",
24
+ "DataSaverMode": "xF6sXnKJfS2AOylzxRjf6A",
25
+ "ExploreSidebar": "zJF1HRGpRjCStcTNm5aekA",
26
+ # ledger writes (POST)
27
+ "HomeTimeline": "og4a4SdSF3WiQkkwaPCdPg",
28
+ "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
29
+ "CreateRetweet": "mbRO74GrOvSfRcJnlMapnQ",
30
+ "CreateTweet": "GYdIGqVWfZNho79bQ2XDoA", # txn-id REQUIRED
31
+ "usePremiumPaywallOnLoadMutation": "F6gikc1Bwzry7oHMrdrYzg",
32
+ "PutClientEducationFlag": "IjQ-egg0uPkY11NyPMfRMQ",
33
+ # discovered in JS, verified live
34
+ "Viewer": "9t128XgFic52jPUEkJMf6w",
35
+ "TweetResultByRestId": "Xl0tsHf4AzflMRjbw9e70A",
36
+ "TweetDetail": "zoF7_t363wZyzylk-BLfZQ",
37
+ "UserTweets": "jeAA-59Y9FL7FmjgBNIVPw",
38
+ "UserRepliesTimeline": "xz348nziCm96wndJ1S0MUQ",
39
+ "UserMedia": "TwLiEVUhRjjjKVc98IT0TQ",
40
+ "Likes": "XHn_Tw60c6pi0n3DGhpwiA",
41
+ "UserRepostsTimeline": "CnNg1YiKG7bRgi-RoIfKsw",
42
+ "UserVideoTimeline": "8FjnVm2ZmIymF6l1Tw2fBQ",
43
+ "UserHighlightsTweets": "TtovDhtuqVtzzfZ9fpztlw",
44
+ "UserArticlesTweets": "ic9_Nc9wg4whcOEBiWAP4w",
45
+ "UserPhotoTimeline": "-7Kl-CfOGgXpmvSvNxuPpA",
46
+ "UserTweetsAndReplies": "wI-ubAWfScnG6odLK4XgCg", # POST only
47
+ "UserSuperFollowTweets": "6SjR3fvrAJwMXjnYF-0KEQ",
48
+ "UserPromotedTweets": "K8KVrFEj0ec0WnOZOtTpaA",
49
+ "UserPromotableTweets": "bwNtu6M5zSf94E7TmbrRIA",
50
+ "UserByRestId": "IdmRdjYxIGI39Hdwkwo5cQ",
51
+ "UsersByRestIds": "BuQFwM7wpHl00cfHL-r0rA", # POST only
52
+ "UsersByScreenNames": "8G9O4pAkWTXNv3XQuOGVrw",
53
+ "SearchTimeline": "auLkqtmHqYEpRvflfvLhyQ", # POST only
54
+ "ExplorePage": "7JBlImZfRkZIptknshoqCA",
55
+ "SupportedLanguages": "fZ5uZVeledO5SAseKnmTUg",
56
+ "GetUserClaims": "aQ-b88K_Lp7dgHX53MqNQQ",
57
+ "UserPreferences": "xFxU-O8hEYe74ovNVU74jA",
58
+ "UserSessionsList": "vJ-XatpmQSG8bDch8-t9Jw",
59
+ "Upsells": "Sg3BvwapuCMIjLJ7LGPhMA",
60
+ "PinnableTimelines": "XUpSmUE_n6ez-U_SdC7G7g",
61
+ "MutedAccounts": "doSy_eEELN6ol2addK5OfA",
62
+ "BlockedAccountsAll": "VlsRzrlUBsLKHvQdoYdSGw",
63
+ "BlockedAccountsImported": "S05B7hxH_1XPxdMiKT-VlQ",
64
+ "ModeratedTimeline": "kO0Oq6PcLpvQacRyW3LIPw",
65
+ "ConnectTabTimeline": "2a5nvYPVhrgXZqTUg8O5ZA",
66
+ "BookmarkSearchTimeline": "rbwiGBFqb93lmG7mw_OYZQ",
67
+ "ListSearchTimeline": "i4096Pm5N66WDAdjumVLrQ",
68
+ "SimilarPosts": "pnl_SipPG1I4d2CBGCEgQw",
69
+ "TrendRelevantUsers": "Av5yDFt1dtyBu2vhvvTA1g",
70
+ "TrendHistory": "2e9kT9dZau_UsQy29Ssj8Q",
71
+ "FinanceSearchTags": "4VTsYZ3r4xYW9S0Rys5G0A",
72
+ "GetUsernameAvailabilityAndSuggestions": "1bMz-9lPrmIXrhFmXntTHw", # POST
73
+ "ProfileUserPhoneState": "5kUWP8C1hcd6omvg6HXXTQ",
74
+ "SuperFollowers": "4A1krY2Xn7uTA4tGOM6Gzw",
75
+ "UserCreatorSubscriptions": "NteGelNq-3jzn3gbc7fyvA",
76
+ "UserCreatorSubscribers": "ZKWe719CjFmlxZM95aw_yQ",
77
+ "FollowersYouKnow": "hEiuVHcQAxCsfSE9aL2e_g",
78
+ "MediaTabVideoMixer": "fGRGW6Se0cRPDbyIK3wjTA",
79
+ "BakeryQuery": "pROR-yRiBVsEjJyHt3fvhg",
80
+ "GlobalCommunitiesPostSearchTimeline": "tbfwt3lMoSiVODtsBmEFGQ",
81
+ "GlobalCommunitiesLatestPostSearchTimeline": "tKfhQuBk1YpT2yhpMng0Gw",
82
+ "TVHomeMixer": "Mxc2_ki1khctdccHe5LN6A",
83
+ "PaymentsUsersTypeahead": "dlHFEO5q9vveDgITz9zELg",
84
+ # mutations, all verified live (pairs restore state)
85
+ "UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
86
+ "DeleteRetweet": "ZyZigVsNiFO6v1dEks1eWg", # source_tweet_id = ORIGINAL id
87
+ "CreateBookmark": "aoDbu3RHznuiSkQ9aNM67Q", # txn-id REQUIRED
88
+ "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
89
+ "DownvoteTweet": "Iu4kUV4vd_iHMupiHPPrAQ",
90
+ "UndoDownvoteTweet": "yqhcbdyy59k-FCwOysvvGQ",
91
+ "DeleteTweet": "nxpZCY2K-I6QoFHAHeojFQ",
92
+ "PinTweet": "VIHsNu89pK-kW35JpHq7Xw",
93
+ "UnpinTweet": "BhKei844ypCyLYCg0nwigw",
94
+ "dmBlockUser": "IYw9u1KEhrS-t-BXsau4Uw",
95
+ "dmUnblockUser": "Krbs6Nak_o7liWQwfV1jOQ",
96
+ "AddContentDisclosure": "D1nwFlsu_qHsX92YzoRaaA",
97
+ "AuthenticatePeriscope": "r7VUmxbfqNkx7uwjgONSNw",
98
+ "CommunityMemberRelationshipTypeahead": "6YgvBKI7c3YZ9d7zKKojng",
99
+ "CommunityUserRelationshipTypeahead": "MOKE3VjNMJ7BVsWdNkv32g",
100
+ "ConversationControlChange": "57WYJNnWH0vM3Ip_gm8B2g",
101
+ "ConversationControlDelete": "OoMO_aSZ1ZXjegeamF9QmA",
102
+ "CreateHighlight": "7jEc7ECTTDcNaqsMhjTxXg",
103
+ "CreateNoteTweet": "u-Vsy3HZId2j10nbdqMI0A",
104
+ "DeleteContentDisclosure": "YeIV-eqGwEZXDtYaDsJz2Q",
105
+ "DeleteHighlight": "ea-VVDSLIEYNY2_2aPg3Uw",
106
+ "DisableVerifiedPhoneLabel": "g2m0pAOamawNtVIfjXNMJg",
107
+ "DmNsfwMediaFilterUpdate": "of_N6O33zfyD4qsFJMYFxA",
108
+ "EnableVerifiedPhoneLabel": "C3RJFfMsb_KcEytpKmRRkw",
109
+ "GenericTimelineById": "6KKcBTcCsVtXvAx4aFDzRQ",
110
+ "ModerateTweet": "pjFnHGVqCjTcZol0xcBJjw",
111
+ "PinTimeline": "ZMYcJPUQ0QIAyWekMdAuDw",
112
+ "ProfileFilter": "O6BaAhZzuUp7_TTTynMF2g",
113
+ "RemoveFollower": "QpNfg0kpPRfjROQ_9eOLXA",
114
+ "SharingAudiospacesListeningDataWithFollowersUpdate": "5h0kNbk3ii97rmfY6CdgAA",
115
+ "TweetResultsByRestIds": "mQnBPxNdkMLfhXINDDYUSg",
116
+ "UnmentionUserFromConversation": "xVW9j3OqoBRY9d6_2OONEg",
117
+ "UnmoderateTweet": "pVSyu6PA57TLvIE4nN2tsA",
118
+ "UnpinTimeline": "_uu6eZuimGwywEa4qtZxZA",
119
+ "UnpinTweet": "BhKei844ypCyLYCg0nwigw",
120
+ "UpdatePinnedTimelines": "AtN-0mKI3fXXmxzYYk1Wqw",
121
+ "UrtFixtures": "Jw90Znej2ooqJH0gx1voEA",
122
+ "UserBusinessProfileTeamTimeline": "v_oniRKLdCZ__sqG-6yTqQ",
123
+ "WriteDataSaverPreferences": "H03etWvZGz41YASxAU2YPg",
124
+ "timelinesFeedback": "vfVbgvTPTQ-dF_PQ5lD1WQ",
125
+ "updateAltTextPromptPreference": "aQKrduk_DA46XfOQDkcEng",
126
+ }
@@ -0,0 +1,20 @@
1
+ """All API resources (one class per area)."""
2
+ from ._base import BaseResource
3
+ from .tweets import Tweets
4
+ from .engagement import Engagement
5
+ from .timelines import Timelines
6
+ from .users import Users
7
+ from .follows import Follows
8
+ from .moderation import Moderation
9
+ from .lists import Lists
10
+ from .dms import DMs
11
+ from .communities import Communities
12
+ from .trends import Trends
13
+ from .notifications import Notifications
14
+ from .settings import Settings
15
+ from .media import Media
16
+ from .misc import Misc
17
+
18
+ __all__ = ["BaseResource", "Tweets", "Engagement", "Timelines", "Users", "Follows",
19
+ "Moderation", "Lists", "DMs", "Communities", "Trends", "Notifications",
20
+ "Settings", "Media", "Misc"]
@@ -0,0 +1,13 @@
1
+ """Shared base for all resources."""
2
+
3
+
4
+ class BaseResource:
5
+ def __init__(self, client):
6
+ self._client = client
7
+
8
+ @property
9
+ def _s(self):
10
+ return self._client.session
11
+
12
+ def _me(self):
13
+ return self._client.me_id
@@ -0,0 +1,34 @@
1
+ """Communities: search, typeaheads, tweet moderation shapes."""
2
+ from ._base import BaseResource
3
+
4
+
5
+ class Communities(BaseResource):
6
+ def post_search(self, raw_query, count=5):
7
+ """Proven: test_communities_post_search.py"""
8
+ return self._s.gql_get("GlobalCommunitiesPostSearchTimeline",
9
+ {"rawQuery": raw_query, "count": count})
10
+
11
+ def latest_search(self, raw_query, count=5):
12
+ """Proven: test_communities_latest_search.py"""
13
+ return self._s.gql_get("GlobalCommunitiesLatestPostSearchTimeline",
14
+ {"rawQuery": raw_query, "count": count})
15
+
16
+ def member_typeahead(self, community_id, prefix="a"):
17
+ """Needs a REAL community id (bogus => CommunityUnavailable).
18
+ Proven: test_community_member_typeahead.py"""
19
+ return self._s.gql_post("CommunityMemberRelationshipTypeahead",
20
+ {"communityId": str(community_id), "prefix": prefix})
21
+
22
+ def user_typeahead(self, community_id, prefix="a"):
23
+ """Proven: test_community_user_typeahead.py"""
24
+ return self._s.gql_post("CommunityUserRelationshipTypeahead",
25
+ {"communityId": str(community_id), "prefix": prefix})
26
+
27
+ def moderate(self, tweet_id):
28
+ """Requires conversation authorship (code 37 otherwise — safe on others' tweets).
29
+ Proven: test_moderate_tweet_shape.py"""
30
+ return self._s.gql_post("ModerateTweet", {"tweetId": str(tweet_id)})
31
+
32
+ def unmoderate(self, tweet_id):
33
+ """Proven: test_unmoderate_tweet_shape.py"""
34
+ return self._s.gql_post("UnmoderateTweet", {"tweetId": str(tweet_id)})