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,83 @@
1
+ """Engagement: like, retweet, bookmark, downvote (all verify by re-read)."""
2
+ from .. import errors as E
3
+ from ._base import BaseResource
4
+
5
+
6
+ class Engagement(BaseResource):
7
+ def _flag(self, tweet_id, key):
8
+ r = self._client.tweets.get(tweet_id)
9
+ return r.get("data", "tweetResult", "result", "legacy", key)
10
+
11
+ # ---- like ----
12
+ def like(self, tweet_id, verify=True):
13
+ """Proven: test_like_pair.py"""
14
+ resp = self._s.gql_post("FavoriteTweet", {"tweet_id": str(tweet_id)})
15
+ E.guard(resp, "FavoriteTweet")
16
+ if verify and self._flag(tweet_id, "favorited") is not True:
17
+ raise E.XVerifyError(f"Tweet {tweet_id} not favorited on re-read.")
18
+ return resp
19
+
20
+ def unlike(self, tweet_id, verify=True):
21
+ """Proven: test_like_pair.py"""
22
+ resp = self._s.gql_post("UnfavoriteTweet", {"tweet_id": str(tweet_id)})
23
+ E.guard(resp, "UnfavoriteTweet")
24
+ if verify and self._flag(tweet_id, "favorited") is not False:
25
+ raise E.XVerifyError(f"Tweet {tweet_id} still favorited on re-read.")
26
+ return resp
27
+
28
+ # ---- retweet ----
29
+ def retweet(self, tweet_id, verify=True):
30
+ """Returns Response; wrapper id at .get("data","create_retweet","retweet_results","result","rest_id").
31
+ Already-retweeted => XAlreadyError (327). Proven: test_retweet_pair.py"""
32
+ resp = self._s.gql_post("CreateRetweet", {"tweet_id": str(tweet_id)})
33
+ E.guard(resp, "CreateRetweet")
34
+ if verify and self._flag(tweet_id, "retweeted") is not True:
35
+ raise E.XVerifyError(f"Tweet {tweet_id} not retweeted on re-read.")
36
+ return resp
37
+
38
+ def unretweet(self, original_tweet_id, verify=True):
39
+ """Pass the ORIGINAL id, never the wrapper (wrapper delete is a silent phantom).
40
+ Proven: test_retweet_pair.py"""
41
+ resp = self._s.gql_post("DeleteRetweet", {"source_tweet_id": str(original_tweet_id)})
42
+ E.guard(resp, "DeleteRetweet")
43
+ if verify and self._flag(original_tweet_id, "retweeted") is not False:
44
+ raise E.XVerifyError(f"Tweet {original_tweet_id} still retweeted (phantom?).")
45
+ return resp
46
+
47
+ # ---- bookmark ----
48
+ def bookmark_add(self, tweet_id, verify=True):
49
+ """CreateBookmark NEEDS txn (404 without). Proven: test_bookmark_pair.py"""
50
+ resp = self._s.gql_post("CreateBookmark", {"tweet_id": str(tweet_id)})
51
+ E.guard(resp, "CreateBookmark")
52
+ if verify and self._flag(tweet_id, "bookmarked") is not True:
53
+ raise E.XVerifyError(f"Tweet {tweet_id} not bookmarked on re-read.")
54
+ return resp
55
+
56
+ def bookmark_remove(self, tweet_id, verify=True):
57
+ """Proven: test_bookmark_pair.py"""
58
+ resp = self._s.gql_post("DeleteBookmark", {"tweet_id": str(tweet_id)})
59
+ E.guard(resp, "DeleteBookmark")
60
+ if verify and self._flag(tweet_id, "bookmarked") is not False:
61
+ raise E.XVerifyError(f"Tweet {tweet_id} still bookmarked on re-read.")
62
+ return resp
63
+
64
+ def bookmark_search(self, query, count=5):
65
+ """Proven: test_bookmark_search.py"""
66
+ return self._s.gql_get("BookmarkSearchTimeline", {"rawQuery": query, "count": count})
67
+
68
+ # ---- downvote ----
69
+ def downvote(self, tweet_id, verify=True):
70
+ """Proven: test_downvote_pair.py"""
71
+ resp = self._s.gql_post("DownvoteTweet", {"tweet_id": str(tweet_id)})
72
+ E.guard(resp, "DownvoteTweet")
73
+ if verify and '"is_downvoted":true' not in resp.raw.replace(" ", ""):
74
+ raise E.XVerifyError("Downvote ack missing is_downvoted:true.")
75
+ return resp
76
+
77
+ def undo_downvote(self, tweet_id, verify=True):
78
+ """Proven: test_downvote_pair.py"""
79
+ resp = self._s.gql_post("UndoDownvoteTweet", {"tweet_id": str(tweet_id)})
80
+ E.guard(resp, "UndoDownvoteTweet")
81
+ if verify and '"is_downvoted":false' not in resp.raw.replace(" ", ""):
82
+ raise E.XVerifyError("Undo-downvote ack missing is_downvoted:false.")
83
+ return resp
@@ -0,0 +1,115 @@
1
+ """Follows & friendships (REST)."""
2
+ from .. import errors as E
3
+ from ._base import BaseResource
4
+
5
+ _FULL = {"include_profile_interstitial_type": 1, "include_blocking": 1,
6
+ "include_blocked_by": 1, "include_followed_by": 1,
7
+ "include_want_retweets": 1, "skip_status": 1}
8
+
9
+
10
+ class Follows(BaseResource):
11
+ def follow(self, user_id, verify=True):
12
+ """Follow + verify via lookup. Proven: test_follow_pair.py"""
13
+ resp = self._s.rest_post_form("friendships/create.json", {**_FULL, "user_id": str(user_id)})
14
+ if verify:
15
+ conns = self.lookup(user_id).get(0, "connections", default=[])
16
+ if "following" not in (conns or []):
17
+ raise E.XVerifyError(f"Follow of {user_id} not confirmed in lookup.")
18
+ return resp
19
+
20
+ def unfollow(self, user_id, verify=True):
21
+ """Unfollow + verify lookup == ['none']. Proven: test_follow_pair.py"""
22
+ resp = self._s.rest_post_form("friendships/destroy.json", {**_FULL, "user_id": str(user_id)})
23
+ if verify:
24
+ conns = self.lookup(user_id).get(0, "connections", default=None)
25
+ if conns != ["none"]:
26
+ raise E.XVerifyError(f"Unfollow of {user_id} not confirmed: {conns}.")
27
+ return resp
28
+
29
+ def lookup(self, user_ids):
30
+ """connections:[...] per user. Proven: test_friendship_lookup.py"""
31
+ if isinstance(user_ids, (list, tuple)):
32
+ user_ids = ",".join(str(i) for i in user_ids)
33
+ return self._s.rest_get("friendships/lookup.json", {"user_id": str(user_ids)})
34
+
35
+ def show(self, source_id=None, target_id=None):
36
+ """Pair relationship. Proven: test_friendship_show.py"""
37
+ return self._s.rest_get("friendships/show.json", {
38
+ "source_id": str(source_id or self._me()), "target_id": str(target_id)})
39
+
40
+ def friends_ids(self, user_id=None):
41
+ """Following ids. Proven: test_friends_ids.py"""
42
+ return self._s.rest_get("friends/ids.json",
43
+ {"user_id": str(user_id or self._me()), "cursor": -1, "count": 5000})
44
+
45
+ def followers_ids(self, user_id=None):
46
+ """Proven: test_followers_ids.py"""
47
+ return self._s.rest_get("followers/ids.json",
48
+ {"user_id": str(user_id or self._me()), "cursor": -1, "count": 5000})
49
+
50
+ def friends_list(self, user_id=None):
51
+ """Following full objects (THE real list). Proven: test_friends_list.py"""
52
+ return self._s.rest_get("friends/list.json",
53
+ {"user_id": str(user_id or self._me()), "cursor": -1,
54
+ "count": 200, "skip_status": 1})
55
+
56
+ def followers_list(self, user_id=None):
57
+ """Proven: test_followers_list.py"""
58
+ return self._s.rest_get("followers/list.json",
59
+ {"user_id": str(user_id or self._me()), "cursor": -1,
60
+ "count": 200, "skip_status": 1})
61
+
62
+ def following_preview(self, user_id=None):
63
+ """LIMITED preview list (not complete!). Proven: test_following_preview_list.py"""
64
+ return self._s.rest_get("friends/following/list.json",
65
+ {"user_id": str(user_id or self._me()), "cursor": -1,
66
+ "count": 200, "skip_status": 1, "with_total_count": "true"})
67
+
68
+ def friendships_list(self, user_id=None):
69
+ """Proven: test_friendships_list.py"""
70
+ return self._s.rest_get("friendships/list.json",
71
+ {"user_id": str(user_id or self._me()), "cursor": -1})
72
+
73
+ def incoming(self):
74
+ """Incoming follow requests. Proven: test_friendships_incoming.py"""
75
+ return self._s.rest_get("friendships/incoming.json", {"cursor": -1, "skip_status": 1})
76
+
77
+ def outgoing(self):
78
+ """Outgoing follow requests. Proven: test_friendships_outgoing.py"""
79
+ return self._s.rest_get("friendships/outgoing.json", {"cursor": -1})
80
+
81
+ def no_retweets_ids(self):
82
+ """Proven: test_no_retweets_ids.py"""
83
+ return self._s.rest_get("friendships/no_retweets/ids.json", {})
84
+
85
+ def set_retweets(self, user_id, enabled, verify=True):
86
+ """Mute/unmute someone's retweets (mutual). Proven: test_friendship_update_pair.py"""
87
+ resp = self._s.rest_post_form("friendships/update.json",
88
+ {"user_id": str(user_id),
89
+ "retweets": "true" if enabled else "false"})
90
+ if verify:
91
+ want = self.show(self._me(), user_id).get("relationship", "source", "want_retweets")
92
+ if want is not bool(enabled):
93
+ raise E.XVerifyError(f"want_retweets={want}, expected {bool(enabled)}.")
94
+ return resp
95
+
96
+ def remove_follower(self, target_user_id, confirm=False):
97
+ """Remove a follower. NEVER auto-fire: pass confirm=True explicitly.
98
+ Shape: bogus id => UnfollowInvalidRequestResult. Proven: test_remove_follower_shape.py"""
99
+ if not confirm:
100
+ raise E.XError("remove_follower() needs confirm=True (irreversible-ish, unsafe to auto-fire).")
101
+ return self._s.gql_post("RemoveFollower", {"target_user_id": str(target_user_id)})
102
+
103
+ def accept(self, user_id):
104
+ """Accept a pending follow request. Routing proven live (409 code 49
105
+ 'No follow request pending' when nothing pending); a REAL accept needs
106
+ an actual pending request to fully prove."""
107
+ return self._s.rest_post_form("friendships/accept.json", {"user_id": str(user_id)})
108
+
109
+ def deny(self, user_id):
110
+ """Deny a pending follow request. Routing proven live (409/49)."""
111
+ return self._s.rest_post_form("friendships/deny.json", {"user_id": str(user_id)})
112
+
113
+ def cancel(self, user_id):
114
+ """Cancel an outgoing follow request. Routing proven live (409/49)."""
115
+ return self._s.rest_post_form("friendships/cancel.json", {"user_id": str(user_id)})
@@ -0,0 +1,75 @@
1
+ """Lists (REST) + profile timeline pins (GraphQL)."""
2
+ from .. import errors as E
3
+ from ._base import BaseResource
4
+
5
+
6
+ class Lists(BaseResource):
7
+ def create(self, name, mode="private", description=""):
8
+ """Returns Response; id at .get("id_str"). Proven: test_list_cycle.py"""
9
+ return self._s.rest_post_form("lists/create.json",
10
+ {"name": name, "mode": mode, "description": description})
11
+
12
+ def destroy(self, list_id):
13
+ """Proven: test_list_cycle.py"""
14
+ return self._s.rest_post_form("lists/destroy.json", {"list_id": str(list_id)})
15
+
16
+ def add_member(self, list_id, user_id):
17
+ """Proven: test_list_cycle.py"""
18
+ return self._s.rest_post_form("lists/members/create.json",
19
+ {"list_id": str(list_id), "user_id": str(user_id)})
20
+
21
+ def remove_member(self, list_id, user_id):
22
+ """Proven: test_list_cycle.py"""
23
+ return self._s.rest_post_form("lists/members/destroy.json",
24
+ {"list_id": str(list_id), "user_id": str(user_id)})
25
+
26
+ def mine(self, user_id=None):
27
+ """Own lists (verify [] when clean). Proven: test_lists_mine.py"""
28
+ return self._s.rest_get("lists/list.json", {"user_id": str(user_id or self._me())})
29
+
30
+ def ownerships(self, user_id=None, count=5):
31
+ """Proven: test_lists_ownerships.py"""
32
+ return self._s.rest_get("lists/ownerships.json",
33
+ {"user_id": str(user_id or self._me()), "count": count})
34
+
35
+ def memberships(self, user_id=None, count=5):
36
+ """Proven: test_lists_memberships.py"""
37
+ return self._s.rest_get("lists/memberships.json",
38
+ {"user_id": str(user_id or self._me()), "count": count})
39
+
40
+ def subscriptions(self, user_id=None):
41
+ """Proven: test_lists_subscriptions.py"""
42
+ return self._s.rest_get("lists/subscriptions.json",
43
+ {"user_id": str(user_id or self._me())})
44
+
45
+ def search(self, list_id, raw_query, count=5):
46
+ """Proven: test_list_search.py"""
47
+ return self._s.gql_get("ListSearchTimeline",
48
+ {"listId": str(list_id), "rawQuery": raw_query, "count": count})
49
+
50
+ # NOTE: lists/members.json READ 404s with every variant — add/remove work, reading does not.
51
+
52
+ # ---- profile pins ----
53
+ def pin(self, item_id, item_type="List", verify=True):
54
+ """Pin a List/Community to the profile (Tag pins are silently ignored).
55
+ Proven: test_pin_timeline_pair.py"""
56
+ item = {"id": str(item_id), "pinned_timeline_type": item_type}
57
+ resp = self._s.gql_post("PinTimeline", {"pinnedTimelineItem": item})
58
+ E.guard(resp, "PinTimeline")
59
+ if verify and "PinTimelineSuccessResult" not in resp.raw:
60
+ raise E.XVerifyError("Pin ack missing PinTimelineSuccessResult.")
61
+ return resp
62
+
63
+ def unpin(self, item_id, item_type="List", verify=True):
64
+ """Proven: test_pin_timeline_pair.py"""
65
+ item = {"id": str(item_id), "pinned_timeline_type": item_type}
66
+ resp = self._s.gql_post("UnpinTimeline", {"pinnedTimelineItem": item})
67
+ E.guard(resp, "UnpinTimeline")
68
+ if verify and "UnpinTimelineSuccessResult" not in resp.raw:
69
+ raise E.XVerifyError("Unpin ack missing UnpinTimelineSuccessResult.")
70
+ return resp
71
+
72
+ def update_pins(self, items):
73
+ """Echo current list = safe no-op. Proven: test_pin_timeline_pair.py"""
74
+ resp = self._s.gql_post("UpdatePinnedTimelines", {"pinnedTimelineItems": items})
75
+ return E.guard(resp, "UpdatePinnedTimelines")
@@ -0,0 +1,150 @@
1
+ """Media: image/video upload + tweet media (photo/video) download."""
2
+ import json
3
+ import os
4
+ import time
5
+ import urllib.parse
6
+ import urllib.request
7
+
8
+ from .. import errors as E
9
+ from ._base import BaseResource
10
+
11
+
12
+ class Media(BaseResource):
13
+ def upload_image(self, image_bytes, media_type="image/png",
14
+ category="tweet_image", alt_text=None):
15
+ """Upload image bytes; returns media_id_string. Orphaned uploads (never attached
16
+ to a tweet) expire unattached. Proven: test_media_upload_cycle.py"""
17
+ p = "/i/media/upload.json"
18
+ init = self._s.call(
19
+ "POST", f"https://upload.x.com{p}?command=INIT&total_bytes={len(image_bytes)}"
20
+ f"&media_type={media_type}&media_category={category}", "")
21
+ mid = init.get("media_id_string")
22
+ if init.status != 202 or not mid:
23
+ raise E.XError(f"Media INIT failed: {init.status} {init.raw[:200]}")
24
+ boundary = "----xapiweb1234"
25
+ mp = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"media\"; "
26
+ f"filename=\"p.png\"\r\nContent-Type: application/octet-stream\r\n\r\n").encode() \
27
+ + image_bytes + f"\r\n--{boundary}--\r\n".encode()
28
+ app = self._s.call(
29
+ "POST", f"https://upload.x.com{p}?command=APPEND&media_id={mid}&segment_index=0",
30
+ mp, f"multipart/form-data; boundary={boundary}")
31
+ if app.status != 204:
32
+ raise E.XError(f"Media APPEND failed: {app.status} {app.raw[:200]}")
33
+ fin = self._s.call("POST", f"https://upload.x.com{p}?command=FINALIZE&media_id={mid}", "")
34
+ if fin.status != 201:
35
+ raise E.XError(f"Media FINALIZE failed: {fin.status} {fin.raw[:200]}")
36
+ if alt_text:
37
+ meta = self._s.call("POST", "https://x.com/i/api/1.1/media/metadata/create.json",
38
+ json.dumps({"media_id": mid, "alt_text": {"text": alt_text}}))
39
+ if meta.status != 200:
40
+ raise E.XError(f"Alt-text failed: {meta.status} {meta.raw[:200]}")
41
+ return mid
42
+
43
+ def upload_video(self, video_bytes, media_type="video/mp4", category="tweet_video",
44
+ chunk_size=1024 * 1024, poll_timeout=180):
45
+ """Chunked video upload (INIT->APPEND*->FINALIZE) + processing poll.
46
+
47
+ Returns media_id_string once processing succeeds; attach via
48
+ tweets.post(text, media_ids=[mid]). Orphaned uploads expire unattached.
49
+ Proven live by xapiweb 2026-09-20."""
50
+ p = "/i/media/upload.json"
51
+ total = len(video_bytes)
52
+ init = self._s.call(
53
+ "POST", f"https://upload.x.com{p}?command=INIT&total_bytes={total}"
54
+ f"&media_type={media_type}&media_category={category}", "")
55
+ mid = init.get("media_id_string")
56
+ if init.status != 202 or not mid:
57
+ raise E.XError(f"Video INIT failed: {init.status} {init.raw[:200]}")
58
+ boundary = "----xapiwebvid"
59
+ for n, start in enumerate(range(0, total, chunk_size)):
60
+ mp = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"media\"; "
61
+ f"filename=\"v.mp4\"\r\nContent-Type: application/octet-stream\r\n\r\n").encode() \
62
+ + video_bytes[start:start + chunk_size] + f"\r\n--{boundary}--\r\n".encode()
63
+ app = self._s.call(
64
+ "POST", f"https://upload.x.com{p}?command=APPEND&media_id={mid}&segment_index={n}",
65
+ mp, f"multipart/form-data; boundary={boundary}", timeout=90)
66
+ if app.status != 204:
67
+ raise E.XError(f"Video APPEND seg {n} failed: {app.status} {app.raw[:200]}")
68
+ fin = self._s.call("POST", f"https://upload.x.com{p}?command=FINALIZE&media_id={mid}",
69
+ "", timeout=90)
70
+ if fin.status not in (200, 201): # video: 200, image: 201
71
+ raise E.XError(f"Video FINALIZE failed: {fin.status} {fin.raw[:200]}")
72
+ info = fin.get("processing_info") or {}
73
+ state = info.get("state")
74
+ t0 = time.time()
75
+ while state in ("pending", "in_progress"):
76
+ if time.time() - t0 > poll_timeout:
77
+ raise E.XError(f"Video processing timed out (media_id={mid}, last={state})")
78
+ time.sleep(info.get("check_after_secs") or 3)
79
+ st = self._s.call("GET", f"https://upload.x.com{p}?command=STATUS&media_id={mid}")
80
+ info = st.get("processing_info") or {}
81
+ state = info.get("state")
82
+ if state == "failed":
83
+ raise E.XError(f"Video processing failed: {json.dumps(info.get('error', info))[:250]}")
84
+ if state != "succeeded":
85
+ raise E.XError(f"Video processing state={state} (media_id={mid})")
86
+ return mid
87
+
88
+ def download(self, url, dest_path, timeout=60):
89
+ """Download a media URL (pbs/video CDN, no auth needed) to dest_path.
90
+ Returns dest_path. Proven live by xapiweb 2026-09-20."""
91
+ req = urllib.request.Request(url, headers={"User-Agent": self._s.user_agent})
92
+ total = 0
93
+ with urllib.request.urlopen(req, timeout=timeout) as resp, open(dest_path, "wb") as f:
94
+ while True:
95
+ chunk = resp.read(65536)
96
+ if not chunk:
97
+ break
98
+ f.write(chunk)
99
+ total += len(chunk)
100
+ if total == 0:
101
+ raise E.XError(f"Downloaded 0 bytes from {url}")
102
+ return dest_path
103
+
104
+ def download_tweet_media(self, tweet_id, dest_dir="."):
105
+ """Download ALL media of a tweet into dest_dir (photo/video/gif).
106
+ Returns [paths]. Proven live by xapiweb 2026-09-20."""
107
+ os.makedirs(dest_dir, exist_ok=True)
108
+ paths = []
109
+ for i, m in enumerate(self._client.tweets.media(tweet_id)):
110
+ if not m.get("url"):
111
+ continue
112
+ if m["type"] in ("video", "animated_gif"):
113
+ ext = ".mp4"
114
+ else:
115
+ ext = os.path.splitext(urllib.parse.urlparse(m["url"]).path)[1] or ".jpg"
116
+ paths.append(self.download(m["url"], os.path.join(dest_dir, f"{tweet_id}_{i}{ext}")))
117
+ return paths
118
+
119
+ # NOTE: image upload stays single-segment (proven); video uses upload_video().
120
+
121
+
122
+ def parse_media_entities(leg):
123
+ """legacy -> [{type, url, thumb, width, height, duration_ms, bitrate, variants}].
124
+
125
+ photo -> direct image url; video/animated_gif -> best (max-bitrate) mp4 url.
126
+ Pure function (offline-testable). Proven live by xapiweb 2026-09-20."""
127
+ out = []
128
+ entities = leg.get("extended_entities") or leg.get("entities") or {}
129
+ for m in entities.get("media") or []:
130
+ if not isinstance(m, dict):
131
+ continue
132
+ typ = m.get("type")
133
+ info = m.get("original_info") or {}
134
+ item = {"type": typ, "id": m.get("id_str"), "thumb": m.get("media_url_https"),
135
+ "width": info.get("width"), "height": info.get("height"),
136
+ "allow_download": ((m.get("allow_download_status") or {}).get("allow_download"))}
137
+ if typ == "photo":
138
+ item["url"] = m.get("media_url_https")
139
+ else:
140
+ vinfo = m.get("video_info") or {}
141
+ variants = vinfo.get("variants") or []
142
+ mp4s = [v for v in variants
143
+ if isinstance(v, dict) and v.get("content_type") == "video/mp4" and v.get("url")]
144
+ best = max(mp4s, key=lambda v: v.get("bitrate") or 0) if mp4s else None
145
+ item["url"] = best["url"] if best else None
146
+ item["bitrate"] = best.get("bitrate") if best else None
147
+ item["duration_ms"] = vinfo.get("duration_millis")
148
+ item["variants"] = variants
149
+ out.append(item)
150
+ return out
@@ -0,0 +1,164 @@
1
+ """Misc: mixers, beacons, probes, and one-off queries."""
2
+ import json
3
+ import urllib.error
4
+ from ._base import BaseResource
5
+
6
+
7
+ class Misc(BaseResource):
8
+ def authenticate_periscope(self):
9
+ """JWT for live/periscope calls. Proven: test_authenticate_periscope.py"""
10
+ return self._s.gql_get("AuthenticatePeriscope", {}, with_features=False, with_toggles=False)
11
+
12
+ def urt_fixtures(self):
13
+ """Proven: test_urt_fixtures.py"""
14
+ return self._s.gql_get("UrtFixtures", {}, with_features=False, with_toggles=False)
15
+
16
+ def bakery(self):
17
+ """Cookie catalog. Proven: test_bakery_cookies.py"""
18
+ return self._s.gql_get("BakeryQuery", {})
19
+
20
+ def supported_languages(self):
21
+ """Proven: test_supported_languages.py"""
22
+ return self._s.gql_get("SupportedLanguages", {})
23
+
24
+ def story_topic(self, rest_id="For You", limit=3):
25
+ """Proven: test_story_topic.py"""
26
+ return self._s.gql_get("useStoryTopicQuery", {"rest_id": rest_id, "limit": limit},
27
+ with_features=False, with_toggles=False)
28
+
29
+ def nfl_follow(self):
30
+ """Proven: test_nfl_follow.py"""
31
+ return self._s.gql_get("NflScoresSidebarFollow", {}, with_features=False, with_toggles=False)
32
+
33
+ def tv_home_mixer(self):
34
+ """Proven: test_tv_home_mixer.py"""
35
+ return self._s.gql_get("TVHomeMixer", {})
36
+
37
+ def payments_typeahead(self, prefix="e"):
38
+ """Proven: test_payments_typeahead.py"""
39
+ return self._s.gql_get("PaymentsUsersTypeahead", {"prefix": prefix})
40
+
41
+ def media_tab_videos(self, user_id=None):
42
+ """Proven: test_media_tab_videos.py"""
43
+ return self._s.gql_get("MediaTabVideoMixer", {"userId": str(user_id or self._me())})
44
+
45
+ def fleetline(self):
46
+ """Proven: test_fleetline.py"""
47
+ return self._s.rest_get("fleets/v1/fleetline", {"only_spaces": "true"},
48
+ host="https://x.com", prefix="/i/api/")
49
+
50
+ def avatar_content(self, user_id=None):
51
+ """Proven: test_avatar_content.py"""
52
+ return self._s.rest_get("fleets/v1/avatar_content",
53
+ {"user_ids": str(user_id or self._me()), "only_spaces": "true"},
54
+ host="https://x.com", prefix="/i/api/")
55
+
56
+ def biz_team_timeline(self, team_name="x", user_id=None):
57
+ """Bogus team => empty timeline. Proven: test_biz_team_timeline.py"""
58
+ return self._s.gql_post("UserBusinessProfileTeamTimeline", {
59
+ "userId": str(user_id or self._me()), "teamName": team_name,
60
+ "includePromotedContent": False})
61
+
62
+ def sidebar_recommendations(self, user_id=None):
63
+ """Proven: test_sidebar_recommendations.py"""
64
+ return self._s.gql_get("SidebarUserRecommendations",
65
+ {"profileUserId": str(user_id or self._me())}, with_toggles=False)
66
+
67
+ def super_followers(self, user_id=None, count=5):
68
+ """Proven: test_super_followers.py"""
69
+ return self._s.gql_get("SuperFollowers", {
70
+ "userId": str(user_id or self._me()), "count": count, "includePromotedContent": False})
71
+
72
+ def premium_paywall(self):
73
+ """Proven: test_premium_paywall.py"""
74
+ return self._s.gql_post("usePremiumPaywallOnLoadMutation", {})
75
+
76
+ def season_schedule(self, user_id=None, days_back=7, days_fwd=14):
77
+ """Proven: test_profile_season_schedule.py"""
78
+ import time
79
+ now = int(time.time() * 1000)
80
+ return self._s.gql_get("ProfileSeasonSchedule", {
81
+ "rest_id": str(user_id or self._me()),
82
+ "start_time": str(now - days_back * 86400 * 1000),
83
+ "end_time": str(now + days_fwd * 86400 * 1000)},
84
+ with_features=False, with_toggles=False)
85
+
86
+ def team_roster(self, user_id=None):
87
+ """Proven: test_profile_team_roster.py"""
88
+ return self._s.gql_get("ProfileTeamRoster", {"rest_id": str(user_id or self._me())},
89
+ with_features=False, with_toggles=False)
90
+
91
+ def vo_upsell(self, screen_name):
92
+ """Proven: test_vo_upsell_eligibility.py"""
93
+ return self._s.gql_get("isEligibleForVoButtonUpsellQuery",
94
+ {"screenName": screen_name,
95
+ "promptPurpose": "x_vo_business_promotion"},
96
+ with_features=False, with_toggles=False)
97
+
98
+ def csp_report(self):
99
+ """CSP beacon sink. Proven: test_csp_report.py"""
100
+ payload = json.dumps({"csp-report": {
101
+ "document-uri": "https://x.com/home", "blocked-uri": "https://example.com/x.js",
102
+ "violated-directive": "script-src", "original-policy": "test",
103
+ "disposition": "enforce"}})
104
+ return self._s.call("POST", "https://x.com/i/csp_report?a=1&ro=0", payload)
105
+
106
+ def log_promoted_view(self, trend_id="117896", impression_id="6752072595616677896",
107
+ event="trend_view"):
108
+ """Promoted-trend impression beacon.
109
+
110
+ NOTE: this is for PROMOTED (ad) content, NOT organic tweet views — it takes a
111
+ trend id, not a tweet id, so it cannot register a view for a post. There is no
112
+ proven endpoint for organic view-logging in the pack. Proven: test_promoted_log.py"""
113
+ return self._s.rest_post_form("promoted_content/log.json", {
114
+ "promoted_trend_id": str(trend_id), "event": event,
115
+ "impression_id": str(impression_id)})
116
+
117
+ def promoted_log(self, trend_id="117896", impression_id="6752072595616677896"):
118
+ """Alias of log_promoted_view() with default test params. Proven: test_promoted_log.py"""
119
+ return self.log_promoted_view(trend_id, impression_id)
120
+
121
+ def manifest_sw(self):
122
+ """Returns (manifest_resp, sw_resp). Proven: test_manifest_sw.py"""
123
+ return (self._s.call("GET", "https://x.com/manifest.json"),
124
+ self._s.call("GET", "https://x.com/sw.js"))
125
+
126
+ def feedback_shape(self):
127
+ """Bogus-token shape only (real fire = irreversible algo signal).
128
+ Proven: test_timelines_feedback_shape.py"""
129
+ return self._s.gql_post("timelinesFeedback",
130
+ {"encoded_feedback_request": "x", "undo": False})
131
+
132
+ def unmention_shape(self, tweet_id):
133
+ """Proven: test_unmention_shape.py (clean error when not mentioned)."""
134
+ return self._s.gql_post("UnmentionUserFromConversation", {"tweet_id": str(tweet_id)})
135
+
136
+ def probe_removed(self):
137
+ """Re-verify legacy endpoints are still gone (expect 404/410, enrollment 403).
138
+ Proven: test_removed_endpoints.py"""
139
+ paths = ["statuses/show.json?id=1", "statuses/lookup.json?id=1", "favorites/list.json",
140
+ "search/adaptive.json?q=x", "guide/topic.json?slug=news", "topics/discover.json",
141
+ "collections/list.json", "people_discovery/modules_urt.json",
142
+ "settings/trends.json", "settings/devices.json", "notifications/all.json",
143
+ "account/login_verification_enrollment.json", "graphql/viewer_context.json"]
144
+ got = {}
145
+ for p in paths:
146
+ try:
147
+ got[p] = self._s.rest_get(p).status
148
+ except urllib.error.HTTPError as e:
149
+ got[p] = e.code
150
+ except Exception as e:
151
+ got[p] = getattr(e, "status", f"ERR {e}"[:40])
152
+ return got
153
+
154
+ def probe_deprecated(self):
155
+ """Re-verify deprecated endpoints still 200-empty. Proven: test_deprecated_empty.py"""
156
+ out = {}
157
+ r1 = self._s.rest_get("users/search.json", {"q": "elon"})
158
+ out["users/search"] = (r1.status, len(r1.raw))
159
+ r2 = self._s.rest_get("statuses/home_timeline.json", {"count": 3})
160
+ out["statuses/home_timeline"] = (r2.status, len(r2.raw))
161
+ r3 = self._s.rest_get("statuses/user_timeline.json",
162
+ {"user_id": self._me(), "count": 3})
163
+ out["statuses/user_timeline"] = (r3.status, len(r3.raw))
164
+ return out