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 +5 -0
- xapiweb-1.5.0/pyproject.toml +9 -0
- xapiweb-1.5.0/setup.cfg +4 -0
- xapiweb-1.5.0/xapiweb/__init__.py +13 -0
- xapiweb-1.5.0/xapiweb/_txn.py +224 -0
- xapiweb-1.5.0/xapiweb/client.py +52 -0
- xapiweb-1.5.0/xapiweb/errors.py +114 -0
- xapiweb-1.5.0/xapiweb/qids.py +126 -0
- xapiweb-1.5.0/xapiweb/resources/__init__.py +20 -0
- xapiweb-1.5.0/xapiweb/resources/_base.py +13 -0
- xapiweb-1.5.0/xapiweb/resources/communities.py +34 -0
- xapiweb-1.5.0/xapiweb/resources/dms.py +48 -0
- xapiweb-1.5.0/xapiweb/resources/engagement.py +83 -0
- xapiweb-1.5.0/xapiweb/resources/follows.py +115 -0
- xapiweb-1.5.0/xapiweb/resources/lists.py +75 -0
- xapiweb-1.5.0/xapiweb/resources/media.py +150 -0
- xapiweb-1.5.0/xapiweb/resources/misc.py +164 -0
- xapiweb-1.5.0/xapiweb/resources/moderation.py +84 -0
- xapiweb-1.5.0/xapiweb/resources/notifications.py +84 -0
- xapiweb-1.5.0/xapiweb/resources/settings.py +102 -0
- xapiweb-1.5.0/xapiweb/resources/timelines.py +228 -0
- xapiweb-1.5.0/xapiweb/resources/trends.py +49 -0
- xapiweb-1.5.0/xapiweb/resources/tweets.py +288 -0
- xapiweb-1.5.0/xapiweb/resources/users.py +85 -0
- xapiweb-1.5.0/xapiweb/response.py +39 -0
- xapiweb-1.5.0/xapiweb/session.py +213 -0
- xapiweb-1.5.0/xapiweb/tests/test_offline.py +162 -0
- xapiweb-1.5.0/xapiweb.egg-info/PKG-INFO +5 -0
- xapiweb-1.5.0/xapiweb.egg-info/SOURCES.txt +29 -0
- xapiweb-1.5.0/xapiweb.egg-info/dependency_links.txt +1 -0
- xapiweb-1.5.0/xapiweb.egg-info/top_level.txt +1 -0
xapiweb-1.5.0/PKG-INFO
ADDED
xapiweb-1.5.0/setup.cfg
ADDED
|
@@ -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,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)})
|