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,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
|