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,84 @@
|
|
|
1
|
+
"""Mutes & blocks (REST + GraphQL)."""
|
|
2
|
+
from .. import errors as E
|
|
3
|
+
from ._base import BaseResource
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Moderation(BaseResource):
|
|
7
|
+
# ---- mutes ----
|
|
8
|
+
def mute(self, user_id, verify=True):
|
|
9
|
+
"""Proven: test_mute_pair.py"""
|
|
10
|
+
resp = self._s.rest_post_form("mutes/users/create.json",
|
|
11
|
+
{"user_id": str(user_id), "skip_status": 1})
|
|
12
|
+
if verify and str(user_id) not in [str(i) for i in (self.mutes_ids().get("ids") or [])]:
|
|
13
|
+
raise E.XVerifyError(f"Mute of {user_id} not confirmed in ids.")
|
|
14
|
+
return resp
|
|
15
|
+
|
|
16
|
+
def unmute(self, user_id, verify=True):
|
|
17
|
+
"""Proven: test_mute_pair.py"""
|
|
18
|
+
resp = self._s.rest_post_form("mutes/users/destroy.json", {"user_id": str(user_id)})
|
|
19
|
+
if verify and str(user_id) in [str(i) for i in (self.mutes_ids().get("ids") or [])]:
|
|
20
|
+
raise E.XVerifyError(f"Unmute of {user_id} not confirmed in ids.")
|
|
21
|
+
return resp
|
|
22
|
+
|
|
23
|
+
def mutes_ids(self):
|
|
24
|
+
"""Proven: test_mutes_ids.py"""
|
|
25
|
+
return self._s.rest_get("mutes/users/ids.json", {"cursor": -1})
|
|
26
|
+
|
|
27
|
+
def mutes_list(self):
|
|
28
|
+
"""Proven: test_mutes_list.py"""
|
|
29
|
+
return self._s.rest_get("mutes/users/list.json", {"cursor": -1})
|
|
30
|
+
|
|
31
|
+
def muted_accounts_gql(self):
|
|
32
|
+
"""Proven: test_muted_accounts_gql.py"""
|
|
33
|
+
return self._s.gql_get("MutedAccounts", {})
|
|
34
|
+
|
|
35
|
+
def advanced_filters(self):
|
|
36
|
+
"""May 404 (dead-ish). Proven: test_mutes_advanced_filters.py"""
|
|
37
|
+
return self._s.rest_get("mutes/advanced_filters.json", {})
|
|
38
|
+
|
|
39
|
+
# ---- blocks ----
|
|
40
|
+
def block(self, user_id, verify=True):
|
|
41
|
+
"""Proven: test_block_pair.py"""
|
|
42
|
+
resp = self._s.rest_post_form("blocks/create.json",
|
|
43
|
+
{"user_id": str(user_id), "skip_status": 1})
|
|
44
|
+
if verify and str(user_id) not in [str(i) for i in (self.blocks_ids().get("ids") or [])]:
|
|
45
|
+
raise E.XVerifyError(f"Block of {user_id} not confirmed in ids.")
|
|
46
|
+
return resp
|
|
47
|
+
|
|
48
|
+
def unblock(self, user_id, verify=True):
|
|
49
|
+
"""Proven: test_block_pair.py"""
|
|
50
|
+
resp = self._s.rest_post_form("blocks/destroy.json", {"user_id": str(user_id)})
|
|
51
|
+
if verify and str(user_id) in [str(i) for i in (self.blocks_ids().get("ids") or [])]:
|
|
52
|
+
raise E.XVerifyError(f"Unblock of {user_id} not confirmed in ids.")
|
|
53
|
+
return resp
|
|
54
|
+
|
|
55
|
+
def blocks_ids(self):
|
|
56
|
+
"""Proven: test_blocks_ids.py"""
|
|
57
|
+
return self._s.rest_get("blocks/ids.json", {"cursor": -1})
|
|
58
|
+
|
|
59
|
+
def blocks_list(self):
|
|
60
|
+
"""Proven: test_blocks_list.py"""
|
|
61
|
+
return self._s.rest_get("blocks/list.json", {"cursor": -1, "skip_status": 1})
|
|
62
|
+
|
|
63
|
+
def blocked_accounts_gql(self, count=5):
|
|
64
|
+
"""Proven: test_blocked_accounts_gql.py"""
|
|
65
|
+
return self._s.gql_get("BlockedAccountsAll", {"count": count})
|
|
66
|
+
|
|
67
|
+
def blocked_imported_gql(self):
|
|
68
|
+
"""Proven: test_blocked_imported_gql.py"""
|
|
69
|
+
return self._s.gql_get("BlockedAccountsImported", {})
|
|
70
|
+
|
|
71
|
+
# ---- DM-specific ----
|
|
72
|
+
def dm_block(self, user_id, verify=True):
|
|
73
|
+
"""Lowercase-dm op; ids differ from REST. Proven: test_dm_block_pair.py"""
|
|
74
|
+
resp = self._s.gql_post("dmBlockUser", {"target_user_id": str(user_id)})
|
|
75
|
+
if verify and "Blocked" not in resp.raw:
|
|
76
|
+
raise E.XVerifyError("dmBlock ack missing 'Blocked'.")
|
|
77
|
+
return resp
|
|
78
|
+
|
|
79
|
+
def dm_unblock(self, user_id, verify=True):
|
|
80
|
+
"""Proven: test_dm_block_pair.py"""
|
|
81
|
+
resp = self._s.gql_post("dmUnblockUser", {"target_user_id": str(user_id)})
|
|
82
|
+
if verify and "Unblocked" not in resp.raw:
|
|
83
|
+
raise E.XVerifyError("dmUnblock ack missing 'Unblocked'.")
|
|
84
|
+
return resp
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Notifications: full list + badge-count polling + realtime SSE."""
|
|
2
|
+
import http.client
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ._base import BaseResource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_notifications(resp_or_data, limit=50):
|
|
9
|
+
"""Pull [{id, type, icon, text, users, time, url, tweet_id}] out of a
|
|
10
|
+
NotificationsTimeline response. type = element like 'users_liked_your_tweet',
|
|
11
|
+
'users_followed_you', 'mention'. users = [{screen_name, name, id}].
|
|
12
|
+
Page with xapiweb.resources.timelines.cursors(resp)['bottom']. xapiweb helper."""
|
|
13
|
+
data = resp_or_data.data if hasattr(resp_or_data, "data") else resp_or_data
|
|
14
|
+
out = []
|
|
15
|
+
|
|
16
|
+
def walk(o):
|
|
17
|
+
if len(out) >= limit:
|
|
18
|
+
return
|
|
19
|
+
if isinstance(o, dict):
|
|
20
|
+
it = o.get("itemContent")
|
|
21
|
+
if isinstance(it, dict) and it.get("__typename") == "TimelineNotification":
|
|
22
|
+
rm = it.get("rich_message") or {}
|
|
23
|
+
users = []
|
|
24
|
+
for ent in rm.get("entities") or []:
|
|
25
|
+
ur = (((ent.get("ref") or {}).get("user_results") or {}).get("result") or {})
|
|
26
|
+
core = ur.get("core") or {}
|
|
27
|
+
leg = ur.get("legacy") or {}
|
|
28
|
+
sn = core.get("screen_name") or leg.get("screen_name")
|
|
29
|
+
if sn:
|
|
30
|
+
users.append({"screen_name": sn,
|
|
31
|
+
"name": core.get("name") or leg.get("name"),
|
|
32
|
+
"id": core.get("rest_id") or ur.get("rest_id")})
|
|
33
|
+
url = (it.get("notification_url") or {}).get("url") or ""
|
|
34
|
+
m = re.search(r"/status/(\d+)", url)
|
|
35
|
+
out.append({"id": it.get("id"),
|
|
36
|
+
"type": (o.get("clientEventInfo") or {}).get("element"),
|
|
37
|
+
"icon": it.get("notification_icon"),
|
|
38
|
+
"text": rm.get("text") or "",
|
|
39
|
+
"users": users,
|
|
40
|
+
"time": it.get("timestamp_ms"),
|
|
41
|
+
"url": url,
|
|
42
|
+
"tweet_id": m.group(1) if m else None})
|
|
43
|
+
for v in o.values():
|
|
44
|
+
walk(v)
|
|
45
|
+
elif isinstance(o, list):
|
|
46
|
+
for v in o:
|
|
47
|
+
walk(v)
|
|
48
|
+
|
|
49
|
+
walk(data)
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Notifications(BaseResource):
|
|
54
|
+
def list(self, timeline_type="All", count=20, cursor=None):
|
|
55
|
+
"""Full notifications list. timeline_type: 'All'|'Verified'|'Mentions'
|
|
56
|
+
(all 200-proven). Parse with extract_notifications(resp); page with
|
|
57
|
+
timelines.cursors(resp)['bottom'] -> cursor=. Proven live 2026-09-20
|
|
58
|
+
(user-captured qid)."""
|
|
59
|
+
v = {"timeline_type": timeline_type, "count": count}
|
|
60
|
+
if cursor:
|
|
61
|
+
v["cursor"] = cursor
|
|
62
|
+
return self._s.gql_get("NotificationsTimeline", v, with_toggles=False)
|
|
63
|
+
|
|
64
|
+
def badge_counts(self):
|
|
65
|
+
"""{dm_unread_count, ntab_unread_count, total_unread_count, ...} — the polling
|
|
66
|
+
endpoint. Proven: test_viewer_badge_counts.py"""
|
|
67
|
+
return self._s.gql_get("ViewerBadgeCounts", {},
|
|
68
|
+
with_features=False, with_toggles=False)
|
|
69
|
+
|
|
70
|
+
def live_events(self, seconds=6, topic=None):
|
|
71
|
+
"""Hold the SSE stream ~`seconds` and return {'status':, 'sample':}.
|
|
72
|
+
Full notifications timeline (AuthTimeline) is UNTESTED. Proven: test_live_pipeline.py"""
|
|
73
|
+
topic = topic or f"/live_content/{self._me()}"
|
|
74
|
+
conn = http.client.HTTPSConnection("api.x.com", timeout=seconds + 6)
|
|
75
|
+
conn.request("GET", f"/live_pipeline/events?topic={topic}",
|
|
76
|
+
headers=self._s.headers("GET", "/live_pipeline/events"))
|
|
77
|
+
resp = conn.getresponse()
|
|
78
|
+
try:
|
|
79
|
+
conn.sock.settimeout(seconds)
|
|
80
|
+
sample = resp.read(300).decode("utf-8", "replace")
|
|
81
|
+
except Exception:
|
|
82
|
+
sample = "(connected, quiet window)"
|
|
83
|
+
conn.close()
|
|
84
|
+
return {"status": resp.status, "topic": topic, "sample": sample[:300]}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Settings & preferences (reads + safe writes; blind writes need confirm=True)."""
|
|
2
|
+
from .. import errors as E
|
|
3
|
+
from ._base import BaseResource
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseResource):
|
|
7
|
+
def account(self):
|
|
8
|
+
"""Txn-gated, alt host. Proven: test_account_settings.py"""
|
|
9
|
+
return self._s.rest_get("account/settings.json", {"include_mention_filter": "true"},
|
|
10
|
+
host="https://api.x.com", prefix="/1.1/")
|
|
11
|
+
|
|
12
|
+
def help_config(self):
|
|
13
|
+
"""Proven: test_help_settings.py"""
|
|
14
|
+
return self._s.rest_get("help/settings.json", {})
|
|
15
|
+
|
|
16
|
+
def email_phone_info(self):
|
|
17
|
+
"""PRIVATE account emails/phones — handle with care. Proven: test_email_phone_info.py"""
|
|
18
|
+
return self._s.rest_get("users/email_phone_info.json", {})
|
|
19
|
+
|
|
20
|
+
def saved_searches(self):
|
|
21
|
+
"""Txn-gated, alive. Proven: test_saved_searches.py"""
|
|
22
|
+
return self._s.rest_get("saved_searches/list.json", {})
|
|
23
|
+
|
|
24
|
+
def alt_text_get(self):
|
|
25
|
+
"""Proven: test_alt_text_preference.py"""
|
|
26
|
+
return self._s.gql_get("getAltTextPromptPreference", {},
|
|
27
|
+
with_features=False, with_toggles=False)
|
|
28
|
+
|
|
29
|
+
def alt_text_set(self, prompt_type="None", verify=True):
|
|
30
|
+
"""'None' = app default (default-equivalent). Proven: test_alt_text_pair.py"""
|
|
31
|
+
resp = self._s.gql_post("updateAltTextPromptPreference", {"promptType": prompt_type})
|
|
32
|
+
E.guard(resp, "updateAltTextPromptPreference")
|
|
33
|
+
if verify:
|
|
34
|
+
back = self._s.gql_post("getAltTextPromptPreference", {})
|
|
35
|
+
if f'"{prompt_type}"' not in back.raw:
|
|
36
|
+
raise E.XVerifyError("Alt-text read-back mismatch.")
|
|
37
|
+
return resp
|
|
38
|
+
|
|
39
|
+
def creator_subscriptions(self, user_id=None):
|
|
40
|
+
"""Proven: test_creator_subscriptions.py"""
|
|
41
|
+
return self._s.gql_get("UserCreatorSubscriptions",
|
|
42
|
+
{"userId": str(user_id or self._me()),
|
|
43
|
+
"includePromotedContent": False})
|
|
44
|
+
|
|
45
|
+
def creator_subscribers(self, user_id=None):
|
|
46
|
+
"""Proven: test_creator_subscribers.py"""
|
|
47
|
+
return self._s.gql_get("UserCreatorSubscribers",
|
|
48
|
+
{"userId": str(user_id or self._me()),
|
|
49
|
+
"includePromotedContent": False})
|
|
50
|
+
|
|
51
|
+
def phone_state(self):
|
|
52
|
+
"""Proven: test_profile_phone_state.py"""
|
|
53
|
+
return self._s.gql_get("ProfileUserPhoneState", {})
|
|
54
|
+
|
|
55
|
+
def multi_accounts(self):
|
|
56
|
+
"""Proven: test_multi_accounts.py"""
|
|
57
|
+
return self._s.rest_get("account/multi/list.json", {})
|
|
58
|
+
|
|
59
|
+
def oauth_apps(self):
|
|
60
|
+
"""Authorized apps. Proven: test_oauth_apps.py"""
|
|
61
|
+
return self._s.rest_get("oauth/list.json", {})
|
|
62
|
+
|
|
63
|
+
def rate_limits(self):
|
|
64
|
+
"""Proven: test_rate_limits.py"""
|
|
65
|
+
return self._s.rest_get("application/rate_limit_status.json", {})
|
|
66
|
+
|
|
67
|
+
def client_education_flag(self, flag="NewUserPromptEducation"):
|
|
68
|
+
"""Proven: test_client_education_flag.py"""
|
|
69
|
+
resp = self._s.gql_post("PutClientEducationFlag", {"flag": flag})
|
|
70
|
+
return E.guard(resp, "PutClientEducationFlag")
|
|
71
|
+
|
|
72
|
+
def phone_label_enable(self):
|
|
73
|
+
"""Code 37 'Cannot find user phone' when no phone. Proven: test_verified_phone_pair.py"""
|
|
74
|
+
return self._s.gql_post("EnableVerifiedPhoneLabel", {})
|
|
75
|
+
|
|
76
|
+
def phone_label_disable(self):
|
|
77
|
+
"""Proven: test_verified_phone_pair.py"""
|
|
78
|
+
return self._s.gql_post("DisableVerifiedPhoneLabel", {})
|
|
79
|
+
|
|
80
|
+
def data_saver_mode(self, device_id="Windows/Firefox"):
|
|
81
|
+
"""Proven: test_data_saver_mode.py"""
|
|
82
|
+
return self._s.gql_get("DataSaverMode", {"device_id": device_id},
|
|
83
|
+
with_features=False, with_toggles=False)
|
|
84
|
+
|
|
85
|
+
def write_data_saver(self, data_saver_enabled, device_id=None, video_autoplay=None, confirm=False):
|
|
86
|
+
"""NEVER auto-fire: current videoAutoplay is unreadable. Shape: test_write_datasaver_shape.py."""
|
|
87
|
+
if not confirm:
|
|
88
|
+
raise E.XError("write_data_saver() needs confirm=True (current value unreadable).")
|
|
89
|
+
v = {"dataSaverEnabled": bool(data_saver_enabled)}
|
|
90
|
+
if device_id is not None:
|
|
91
|
+
v["deviceId"] = device_id
|
|
92
|
+
if video_autoplay is not None:
|
|
93
|
+
v["videoAutoplay"] = video_autoplay
|
|
94
|
+
return self._s.gql_post("WriteDataSaverPreferences", v)
|
|
95
|
+
|
|
96
|
+
def write_audiospaces_sharing(self, user_id=None, sharing=None, confirm=False):
|
|
97
|
+
"""NEVER auto-fire: no read API. Shape: test_sharing_audiospaces_shape.py."""
|
|
98
|
+
if not confirm:
|
|
99
|
+
raise E.XError("write_audiospaces_sharing() needs confirm=True (no read API to restore).")
|
|
100
|
+
return self._s.gql_post("SharingAudiospacesListeningDataWithFollowersUpdate", {
|
|
101
|
+
"userId": str(user_id or self._me()),
|
|
102
|
+
"sharingAudiospacesListeningDataWithFollowers": sharing})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Timelines: home, user timelines, search, followers, pins + URT parsing helpers."""
|
|
2
|
+
from ._base import BaseResource
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def extract_tweets(resp_or_data, limit=50):
|
|
6
|
+
"""Pull [{rest_id, author, text, views, retweeted, favorited, counts}] out of any
|
|
7
|
+
timeline response (walks URT instructions). xapiweb helper."""
|
|
8
|
+
data = resp_or_data.data if hasattr(resp_or_data, "data") else resp_or_data
|
|
9
|
+
tweets, seen = [], set()
|
|
10
|
+
|
|
11
|
+
def walk(obj):
|
|
12
|
+
if len(tweets) >= limit:
|
|
13
|
+
return
|
|
14
|
+
if isinstance(obj, dict):
|
|
15
|
+
tr = obj.get("tweet_results")
|
|
16
|
+
if isinstance(tr, dict):
|
|
17
|
+
r = tr.get("result", {})
|
|
18
|
+
rid = r.get("rest_id") if isinstance(r, dict) else None
|
|
19
|
+
if rid and rid not in seen:
|
|
20
|
+
seen.add(rid)
|
|
21
|
+
leg = r.get("legacy", {}) or {}
|
|
22
|
+
author = "?"
|
|
23
|
+
try:
|
|
24
|
+
author = r["core"]["user_results"]["result"]["legacy"]["screen_name"]
|
|
25
|
+
except Exception:
|
|
26
|
+
try:
|
|
27
|
+
author = r["core"]["user_results"]["result"]["core"]["screen_name"]
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
tweets.append({"rest_id": rid, "author": author,
|
|
31
|
+
"text": leg.get("full_text") or "",
|
|
32
|
+
"views": _views(r),
|
|
33
|
+
"retweeted": leg.get("retweeted"),
|
|
34
|
+
"favorited": leg.get("favorited"),
|
|
35
|
+
"retweet_count": leg.get("retweet_count"),
|
|
36
|
+
"favorite_count": leg.get("favorite_count")})
|
|
37
|
+
for v in obj.values():
|
|
38
|
+
walk(v)
|
|
39
|
+
elif isinstance(obj, list):
|
|
40
|
+
for v in obj:
|
|
41
|
+
walk(v)
|
|
42
|
+
|
|
43
|
+
walk(data.get("data", data) if isinstance(data, dict) else data)
|
|
44
|
+
return tweets
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _views(result):
|
|
48
|
+
"""result.views.count -> int (or None). Lives at result level, not legacy."""
|
|
49
|
+
try:
|
|
50
|
+
return int((result.get("views") or {}).get("count"))
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cursors(resp_or_data):
|
|
56
|
+
"""Return {'top': ..., 'bottom': ...} pagination cursors (may be None). xapiweb helper."""
|
|
57
|
+
data = resp_or_data.data if hasattr(resp_or_data, "data") else resp_or_data
|
|
58
|
+
out = {"top": None, "bottom": None}
|
|
59
|
+
|
|
60
|
+
def walk(obj):
|
|
61
|
+
if isinstance(obj, dict):
|
|
62
|
+
if obj.get("cursorType") in ("Top", "Bottom") and obj.get("value"):
|
|
63
|
+
out[obj["cursorType"].lower()] = obj["value"]
|
|
64
|
+
for v in obj.values():
|
|
65
|
+
walk(v)
|
|
66
|
+
elif isinstance(obj, list):
|
|
67
|
+
for v in obj:
|
|
68
|
+
walk(v)
|
|
69
|
+
|
|
70
|
+
walk(data)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Timelines(BaseResource):
|
|
75
|
+
def home(self, count=5, seen_ids=None):
|
|
76
|
+
"""Home/For-you (POST). Proven: test_home_timeline.py"""
|
|
77
|
+
return self._s.gql_post("HomeTimeline", {
|
|
78
|
+
"count": count, "includePromotedContent": True,
|
|
79
|
+
"requestContext": "launch", "withCommunity": True,
|
|
80
|
+
"seenTweetIds": seen_ids or []}, with_features=True)
|
|
81
|
+
|
|
82
|
+
# ---- user timelines (GET) ----
|
|
83
|
+
def _user_tl(self, op, user_id, count, extra=None):
|
|
84
|
+
v = {"userId": str(user_id or self._me()), "count": count,
|
|
85
|
+
"includePromotedContent": False, "withVoice": True}
|
|
86
|
+
if extra:
|
|
87
|
+
v.update(extra)
|
|
88
|
+
return self._s.gql_get(op, v)
|
|
89
|
+
|
|
90
|
+
def user_tweets(self, user_id=None, count=5):
|
|
91
|
+
"""Proven: test_user_tweets.py"""
|
|
92
|
+
return self._user_tl("UserTweets", user_id, count,
|
|
93
|
+
{"includePromotedContent": True})
|
|
94
|
+
|
|
95
|
+
def user_replies(self, user_id=None, count=5):
|
|
96
|
+
"""Proven: test_user_replies.py"""
|
|
97
|
+
return self._s.gql_get("UserRepliesTimeline",
|
|
98
|
+
{"userId": str(user_id or self._me()), "count": count,
|
|
99
|
+
"includePromotedContent": False, "withVoice": True})
|
|
100
|
+
|
|
101
|
+
def user_media(self, user_id=None, count=5):
|
|
102
|
+
"""Proven: test_user_media.py"""
|
|
103
|
+
return self._user_tl("UserMedia", user_id, count, {"withClientEventToken": False})
|
|
104
|
+
|
|
105
|
+
def user_likes(self, user_id=None, count=5):
|
|
106
|
+
"""Proven: test_user_likes.py"""
|
|
107
|
+
return self._user_tl("Likes", user_id, count, {"withClientEventToken": False})
|
|
108
|
+
|
|
109
|
+
def user_reposts(self, user_id=None, count=5):
|
|
110
|
+
"""Proven: test_user_reposts.py"""
|
|
111
|
+
return self._user_tl("UserRepostsTimeline", user_id, count)
|
|
112
|
+
|
|
113
|
+
def user_video(self, user_id=None, count=5):
|
|
114
|
+
"""Proven: test_user_video_timeline.py"""
|
|
115
|
+
return self._user_tl("UserVideoTimeline", user_id, count, {"withClientEventToken": False})
|
|
116
|
+
|
|
117
|
+
def user_highlights(self, user_id=None, count=5):
|
|
118
|
+
"""Proven: test_user_highlights.py"""
|
|
119
|
+
return self._user_tl("UserHighlightsTweets", user_id, count)
|
|
120
|
+
|
|
121
|
+
def user_articles(self, user_id=None, count=5):
|
|
122
|
+
"""Proven: test_user_articles.py"""
|
|
123
|
+
return self._user_tl("UserArticlesTweets", user_id, count)
|
|
124
|
+
|
|
125
|
+
def user_photo(self, user_id=None, count=5):
|
|
126
|
+
"""Proven: test_user_photo_timeline.py"""
|
|
127
|
+
return self._user_tl("UserPhotoTimeline", user_id, count)
|
|
128
|
+
|
|
129
|
+
def user_originals(self, user_id=None, count=5):
|
|
130
|
+
"""Proven: test_user_originals_timeline.py"""
|
|
131
|
+
return self._s.gql_get("UserOriginalsTimeline",
|
|
132
|
+
{"userId": str(user_id or self._me()), "count": count,
|
|
133
|
+
"includePromotedContent": True,
|
|
134
|
+
"withQuickPromoteEligibilityTweetFields": True, "withVoice": True})
|
|
135
|
+
|
|
136
|
+
def user_tweets_and_replies(self, user_id=None, count=5):
|
|
137
|
+
"""POST-only. Proven: test_user_tweets_and_replies.py"""
|
|
138
|
+
return self._s.gql_post("UserTweetsAndReplies", {
|
|
139
|
+
"userId": str(user_id or self._me()), "count": count,
|
|
140
|
+
"includePromotedContent": False, "withVoice": True}, with_features=True)
|
|
141
|
+
|
|
142
|
+
def user_super_follow(self, user_id=None, count=5):
|
|
143
|
+
"""Proven: test_user_super_follow_tweets.py"""
|
|
144
|
+
return self._user_tl("UserSuperFollowTweets", user_id, count)
|
|
145
|
+
|
|
146
|
+
def user_promoted(self, user_id=None, count=5):
|
|
147
|
+
"""Proven: test_user_promoted_tweets.py"""
|
|
148
|
+
return self._s.gql_get("UserPromotedTweets",
|
|
149
|
+
{"userId": str(user_id or self._me()), "count": count})
|
|
150
|
+
|
|
151
|
+
def user_promotable(self, user_id=None, count=5):
|
|
152
|
+
"""Proven: test_user_promotable_tweets.py"""
|
|
153
|
+
return self._s.gql_get("UserPromotableTweets",
|
|
154
|
+
{"userId": str(user_id or self._me()), "count": count})
|
|
155
|
+
|
|
156
|
+
# ---- follow graphs (GraphQL) ----
|
|
157
|
+
def followers(self, user_id=None, count=5):
|
|
158
|
+
"""POST-only (GET 404s). Proven: test_followers_gql.py"""
|
|
159
|
+
return self._s.gql_post("Followers", {
|
|
160
|
+
"userId": str(user_id or self._me()), "count": count,
|
|
161
|
+
"includePromotedContent": False}, with_features=True)
|
|
162
|
+
|
|
163
|
+
def following(self, user_id=None, count=5):
|
|
164
|
+
"""Proven: test_following_gql.py"""
|
|
165
|
+
return self._s.gql_get("Following", {
|
|
166
|
+
"userId": str(user_id or self._me()), "count": count,
|
|
167
|
+
"includePromotedContent": False}, with_toggles=False)
|
|
168
|
+
|
|
169
|
+
def blue_verified_followers(self, user_id=None, count=5):
|
|
170
|
+
"""Proven: test_blue_verified_followers.py"""
|
|
171
|
+
return self._s.gql_get("BlueVerifiedFollowers", {
|
|
172
|
+
"userId": str(user_id or self._me()), "count": count,
|
|
173
|
+
"includePromotedContent": False}, with_toggles=False)
|
|
174
|
+
|
|
175
|
+
def followers_you_know(self, user_id=None, count=5):
|
|
176
|
+
"""Proven: test_followers_you_know.py"""
|
|
177
|
+
return self._s.gql_get("FollowersYouKnow", {
|
|
178
|
+
"userId": str(user_id or self._me()), "count": count,
|
|
179
|
+
"includePromotedContent": False})
|
|
180
|
+
|
|
181
|
+
# ---- search ----
|
|
182
|
+
def search(self, raw_query, count=5, product="Top"):
|
|
183
|
+
"""POST-only. Proven: test_search_timeline.py"""
|
|
184
|
+
return self._s.gql_post("SearchTimeline", {
|
|
185
|
+
"rawQuery": raw_query, "count": count,
|
|
186
|
+
"querySource": "typed_query", "product": product}, with_features=True)
|
|
187
|
+
|
|
188
|
+
def list_search(self, list_id, raw_query, count=5):
|
|
189
|
+
"""Proven: test_list_search.py"""
|
|
190
|
+
return self._s.gql_get("ListSearchTimeline",
|
|
191
|
+
{"listId": str(list_id), "rawQuery": raw_query, "count": count})
|
|
192
|
+
|
|
193
|
+
def communities_post_search(self, raw_query, count=5):
|
|
194
|
+
"""Proven: test_communities_post_search.py"""
|
|
195
|
+
return self._s.gql_get("GlobalCommunitiesPostSearchTimeline",
|
|
196
|
+
{"rawQuery": raw_query, "count": count})
|
|
197
|
+
|
|
198
|
+
def communities_latest_search(self, raw_query, count=5):
|
|
199
|
+
"""Proven: test_communities_latest_search.py"""
|
|
200
|
+
return self._s.gql_get("GlobalCommunitiesLatestPostSearchTimeline",
|
|
201
|
+
{"rawQuery": raw_query, "count": count})
|
|
202
|
+
|
|
203
|
+
# ---- misc timelines ----
|
|
204
|
+
def generic_by_id(self, timeline_id):
|
|
205
|
+
"""Ids are opaque server handles; bogus id => routed 200 error. Proven: test_generic_timeline.py"""
|
|
206
|
+
return self._s.gql_get("GenericTimelineById", {"timelineId": str(timeline_id)},
|
|
207
|
+
with_features=False, with_toggles=False)
|
|
208
|
+
|
|
209
|
+
def moderated(self, root_tweet_id, count=5):
|
|
210
|
+
"""Community moderation queue view. Proven: test_moderated_timeline.py"""
|
|
211
|
+
return self._s.gql_get("ModeratedTimeline", {
|
|
212
|
+
"rootTweetId": str(root_tweet_id), "count": count, "includePromotedContent": False})
|
|
213
|
+
|
|
214
|
+
def pinned(self, user_id=None):
|
|
215
|
+
"""Profile pinned timelines. Proven: test_pinned_timelines.py"""
|
|
216
|
+
return self._s.gql_get("PinnedTimelines", {"userId": str(user_id or self._me())},
|
|
217
|
+
with_toggles=False)
|
|
218
|
+
|
|
219
|
+
def pinnable(self):
|
|
220
|
+
"""Proven: test_pinnable_timelines.py"""
|
|
221
|
+
return self._s.gql_get("PinnableTimelines", {})
|
|
222
|
+
|
|
223
|
+
def profile_filter(self, user_id=None, bucket="posts"):
|
|
224
|
+
"""BROKEN server-side (timeline.bucket 500s for every user, 2026-09-19).
|
|
225
|
+
Other buckets => 422. Proven: test_profile_filter.py"""
|
|
226
|
+
return self._s.gql_post("ProfileFilter", {
|
|
227
|
+
"userId": str(user_id or self._me()), "includePromotedContent": False,
|
|
228
|
+
"withVoice": True, "bucket": bucket})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Trends, explore, search helpers, geo."""
|
|
2
|
+
from ._base import BaseResource
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Trends(BaseResource):
|
|
6
|
+
def available(self):
|
|
7
|
+
"""Trend locations (woeids). Proven: test_trends_available.py"""
|
|
8
|
+
return self._s.rest_get("trends/available.json", {})
|
|
9
|
+
|
|
10
|
+
def place(self, woeid=1):
|
|
11
|
+
"""Proven: test_trends_place.py"""
|
|
12
|
+
return self._s.rest_get("trends/place.json", {"id": woeid})
|
|
13
|
+
|
|
14
|
+
def history(self, trend_id):
|
|
15
|
+
"""Proven: test_trend_history.py"""
|
|
16
|
+
return self._s.gql_get("TrendHistory", {"trendId": str(trend_id)})
|
|
17
|
+
|
|
18
|
+
def relevant_users(self, trend_id):
|
|
19
|
+
"""Proven: test_trend_relevant_users.py"""
|
|
20
|
+
return self._s.gql_get("TrendRelevantUsers", {"trendId": str(trend_id)})
|
|
21
|
+
|
|
22
|
+
def sidebar(self):
|
|
23
|
+
"""Explore sidebar. Proven: test_explore_sidebar.py"""
|
|
24
|
+
return self._s.gql_get("ExploreSidebar", {}, with_toggles=False)
|
|
25
|
+
|
|
26
|
+
def explore_page(self, candidate="news", tab="news"):
|
|
27
|
+
"""Proven: test_explore_page.py"""
|
|
28
|
+
return self._s.gql_get("ExplorePage", {"candidateId": candidate, "tabId": tab})
|
|
29
|
+
|
|
30
|
+
def connect_tab(self, count=5):
|
|
31
|
+
"""Proven: test_connect_tab.py"""
|
|
32
|
+
return self._s.gql_get("ConnectTabTimeline", {"count": count})
|
|
33
|
+
|
|
34
|
+
def creator_studio_tab(self):
|
|
35
|
+
"""Proven: test_creator_studio_tab.py"""
|
|
36
|
+
return self._s.gql_get("CreatorStudioTabBarItemQuery", {},
|
|
37
|
+
with_features=False, with_toggles=False)
|
|
38
|
+
|
|
39
|
+
def finance_tags(self, query):
|
|
40
|
+
"""Proven: test_finance_tags.py"""
|
|
41
|
+
return self._s.gql_get("FinanceSearchTags", {"query": query})
|
|
42
|
+
|
|
43
|
+
def geo_search(self, query):
|
|
44
|
+
"""E.g. 'Colombo'. Proven: test_geo_search.py"""
|
|
45
|
+
return self._s.rest_get("geo/search.json", {"query": query})
|
|
46
|
+
|
|
47
|
+
def typeahead(self, q):
|
|
48
|
+
"""Search typeahead. Proven: test_search_typeahead.py"""
|
|
49
|
+
return self._s.rest_get("search/typeahead.json", {"q": q, "src": "search_box"})
|