Skip to content

Commit 5cdeb53

Browse files
committed
v2.1.0
1 parent a6d96be commit 5cdeb53

File tree

13 files changed

+1074
-30
lines changed

13 files changed

+1074
-30
lines changed

examples/guest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import asyncio
2+
3+
from twikit.guest import GuestClient
4+
5+
client = GuestClient()
6+
7+
8+
async def main():
9+
# Activate the client by generating a guest token.
10+
await client.activate()
11+
12+
# Get user by screen name
13+
user = await client.get_user_by_screen_name('elonmusk')
14+
print(user)
15+
# Get user by ID
16+
user = await client.get_user_by_id('44196397')
17+
print(user)
18+
19+
20+
user_tweets = await client.get_user_tweets('44196397')
21+
print(user_tweets)
22+
23+
tweet = await client.get_tweet_by_id('1519480761749016577')
24+
print(tweet)
25+
26+
asyncio.run(main())

twikit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
A Python library for interacting with the Twitter API.
88
"""
99

10-
__version__ = '2.0.3'
10+
__version__ = '2.1.0'
1111

1212
import asyncio
1313
import os

twikit/client/client.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from ..trend import Location, PlaceTrend, PlaceTrends, Trend
4444
from ..tweet import CommunityNote, Poll, ScheduledTweet, Tweet, tweet_from_data
4545
from ..user import User
46-
from ..utils import Flow, Result, build_tweet_data, build_user_data, find_dict, httpx_transport_to_url
46+
from ..utils import Flow, Result, build_tweet_data, build_user_data, find_dict, find_entry_by_type, httpx_transport_to_url
4747
from .gql import GQLClient
4848
from .v11 import V11Client
4949

@@ -751,6 +751,73 @@ async def get_similar_tweets(self, tweet_id: str) -> list[Tweet]:
751751

752752
return results
753753

754+
async def get_user_highlights_tweets(
755+
self,
756+
user_id: str,
757+
count: int = 20,
758+
cursor: str | None = None
759+
) -> Result[Tweet]:
760+
"""
761+
Retrieves highlighted tweets from a user's timeline.
762+
763+
Parameters
764+
----------
765+
user_id : :class:`str`
766+
The user ID
767+
count : :class:`int`, default=20
768+
The number of tweets to retrieve.
769+
770+
Returns
771+
-------
772+
Result[:class:`Tweet`]
773+
An instance of the `Result` class containing the highlighted tweets.
774+
775+
Examples
776+
--------
777+
>>> result = await client.get_user_highlights_tweets('123456789')
778+
>>> for tweet in result:
779+
... print(tweet)
780+
<Tweet id="...">
781+
<Tweet id="...">
782+
...
783+
...
784+
785+
>>> more_results = await result.next() # Retrieve more highlighted tweets
786+
>>> for tweet in more_results:
787+
... print(tweet)
788+
<Tweet id="...">
789+
<Tweet id="...">
790+
...
791+
...
792+
"""
793+
response, _ = await self.gql.user_highlights_tweets(user_id, count, cursor)
794+
795+
instructions = response['data']['user']['result']['timeline']['timeline']['instructions']
796+
instruction = find_entry_by_type(instructions, 'TimelineAddEntries')
797+
if instruction is None:
798+
return Result.empty()
799+
entries = instruction['entries']
800+
previous_cursor = None
801+
next_cursor = None
802+
results = []
803+
804+
for entry in entries:
805+
entryId = entry['entryId']
806+
if entryId.startswith('tweet'):
807+
results.append(tweet_from_data(self, entry))
808+
elif entryId.startswith('cursor-top'):
809+
previous_cursor = entry['content']['value']
810+
elif entryId.startswith('cursor-bottom'):
811+
next_cursor = entry['content']['value']
812+
813+
return Result(
814+
results,
815+
partial(self.get_user_highlights_tweets, user_id, count, next_cursor),
816+
next_cursor,
817+
partial(self.get_user_highlights_tweets, user_id, count, previous_cursor),
818+
previous_cursor
819+
)
820+
754821
async def upload_media(
755822
self,
756823
source: str | bytes,

twikit/client/gql.py

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@
1111
LIST_FEATURES,
1212
NOTE_TWEET_FEATURES,
1313
SIMILAR_POSTS_FEATURES,
14-
USER_FEATURES
14+
TWEET_RESULT_BY_REST_ID_FEATURES,
15+
USER_FEATURES,
16+
USER_HIGHLIGHTS_TWEETS_FEATURES
1517
)
1618
from ..utils import flatten_params, get_query_id
1719

1820
if TYPE_CHECKING:
21+
from ..guest.client import GuestClient
1922
from .client import Client
2023

24+
ClientType = Client | GuestClient
25+
2126

2227
class Endpoint:
2328
@staticmethod
@@ -33,6 +38,7 @@ def url(path):
3338
USER_BY_SCREEN_NAME = url('NimuplG1OB7Fd2btCLdBOw/UserByScreenName')
3439
USER_BY_REST_ID = url('tD8zKvQzwY3kdx5yz6YmOw/UserByRestId')
3540
TWEET_DETAIL = url('U0HTv-bAWTBYylwEMT7x5A/TweetDetail')
41+
TWEET_RESULT_BY_REST_ID = url('Xl5pC_lBk_gcO2ItU39DQw/TweetResultByRestId')
3642
FETCH_SCHEDULED_TWEETS = url('ITtjAzvlZni2wWXwf295Qg/FetchScheduledTweets')
3743
DELETE_SCHEDULED_TWEET = url('CTOVqej0JBXAZSwkp1US0g/DeleteScheduledTweet')
3844
RETWEETERS = url('X-XEqG5qHQSAwmvy00xfyQ/Retweeters')
@@ -42,6 +48,7 @@ def url(path):
4248
USER_TWEETS_AND_REPLIES = url('vMkJyzx1wdmvOeeNG0n6Wg/UserTweetsAndReplies')
4349
USER_MEDIA = url('2tLOJWwGuCTytDrGBg8VwQ/UserMedia')
4450
USER_LIKES = url('IohM3gxQHfvWePH5E3KuNA/Likes')
51+
USER_HIGHLIGHTS_TWEETS = url('tHFm_XZc_NNi-CfUThwbNw/UserHighlightsTweets')
4552
HOME_TIMELINE = url('-X_hcgQzmHGl29-UXxz4sw/HomeTimeline')
4653
HOME_LATEST_TIMELINE = url('U0cdisy7QFIoTfu3-Okw0A/HomeLatestTimeline')
4754
FAVORITE_TWEET = url('lI07N6Otwv1PhnEgXILM7A/FavoriteTweet')
@@ -92,7 +99,7 @@ def url(path):
9299

93100

94101
class GQLClient:
95-
def __init__(self, base: Client) -> None:
102+
def __init__(self, base: ClientType) -> None:
96103
self.base = base
97104

98105
async def gql_get(
@@ -320,27 +327,43 @@ async def user_media(self, user_id, count, cursor):
320327
async def user_likes(self, user_id, count, cursor):
321328
return await self._get_user_tweets(user_id, count, cursor, Endpoint.USER_LIKES)
322329

330+
async def user_highlights_tweets(self, user_id, count, cursor):
331+
variables = {
332+
'userId': user_id,
333+
'count': count,
334+
'includePromotedContent': True,
335+
'withVoice': True
336+
}
337+
if cursor is not None:
338+
variables['cursor'] = cursor
339+
return await self.gql_get(
340+
Endpoint.USER_HIGHLIGHTS_TWEETS,
341+
variables,
342+
USER_HIGHLIGHTS_TWEETS_FEATURES,
343+
self.base._base_headers
344+
)
345+
323346
async def home_timeline(self, count, seen_tweet_ids, cursor):
324347
variables = {
325-
"count": count,
326-
"includePromotedContent": True,
327-
"latestControlAvailable": True,
328-
"requestContext": "launch",
329-
"withCommunity": True,
330-
"seenTweetIds": seen_tweet_ids or []
348+
'count': count,
349+
'includePromotedContent': True,
350+
'latestControlAvailable': True,
351+
'requestContext': 'launch',
352+
'withCommunity': True,
353+
'seenTweetIds': seen_tweet_ids or []
331354
}
332355
if cursor is not None:
333356
variables['cursor'] = cursor
334357
return await self.gql_post(Endpoint.HOME_TIMELINE, variables, FEATURES)
335358

336359
async def home_latest_timeline(self, count, seen_tweet_ids, cursor):
337360
variables = {
338-
"count": count,
339-
"includePromotedContent": True,
340-
"latestControlAvailable": True,
341-
"requestContext": "launch",
342-
"withCommunity": True,
343-
"seenTweetIds": seen_tweet_ids or []
361+
'count': count,
362+
'includePromotedContent': True,
363+
'latestControlAvailable': True,
364+
'requestContext': 'launch',
365+
'withCommunity': True,
366+
'seenTweetIds': seen_tweet_ids or []
344367
}
345368
if cursor is not None:
346369
variables['cursor'] = cursor
@@ -632,16 +655,38 @@ async def moderators_slice_timeline_query(self, community_id, count, cursor):
632655

633656
async def community_tweet_search_module_query(self, community_id, query, count, cursor):
634657
variables = {
635-
"count": count,
636-
"query": query,
637-
"communityId": community_id,
638-
"includePromotedContent": False,
639-
"withBirdwatchNotes": True,
640-
"withVoice": False,
641-
"isListMemberTargetUserId": "0",
642-
"withCommunity": False,
643-
"withSafetyModeUserFields": True
658+
'count': count,
659+
'query': query,
660+
'communityId': community_id,
661+
'includePromotedContent': False,
662+
'withBirdwatchNotes': True,
663+
'withVoice': False,
664+
'isListMemberTargetUserId': '0',
665+
'withCommunity': False,
666+
'withSafetyModeUserFields': True
644667
}
645668
if cursor is not None:
646669
variables['cursor'] = cursor
647670
return await self.gql_get(Endpoint.COMMUNITY_TWEET_SEARCH_MODULE_QUERY, variables, COMMUNITY_TWEETS_FEATURES)
671+
672+
####################
673+
# For guest client
674+
####################
675+
676+
async def tweet_result_by_rest_id(self, tweet_id):
677+
variables = {
678+
'tweetId': tweet_id,
679+
'withCommunity': False,
680+
'includePromotedContent': False,
681+
'withVoice': False
682+
}
683+
params = {
684+
'fieldToggles': {
685+
'withArticleRichContentState': True,
686+
'withArticlePlainText': False,
687+
'withGrokAnalyze': False
688+
}
689+
}
690+
return await self.gql_get(
691+
Endpoint.TWEET_RESULT_BY_REST_ID, variables, TWEET_RESULT_BY_REST_ID_FEATURES, extra_params=params
692+
)

twikit/client/v11.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
from typing import TYPE_CHECKING
55

66
if TYPE_CHECKING:
7+
from ..guest.client import GuestClient
78
from .client import Client
89

10+
ClientType = Client | GuestClient
11+
912

1013
class Endpoint:
1114
GUEST_ACTIVATE = 'https://api.twitter.com/1.1/guest/activate.json'
@@ -45,13 +48,13 @@ class Endpoint:
4548

4649

4750
class V11Client:
48-
def __init__(self, base: Client) -> None:
51+
def __init__(self, base: ClientType) -> None:
4952
self.base = base
5053

5154
async def guest_activate(self):
5255
headers = self.base._base_headers
53-
headers.pop('X-Twitter-Active-User')
54-
headers.pop('X-Twitter-Auth-Type')
56+
headers.pop('X-Twitter-Active-User', None)
57+
headers.pop('X-Twitter-Auth-Type', None)
5558
return await self.base.post(
5659
Endpoint.GUEST_ACTIVATE,
5760
headers=headers,

twikit/constants.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,57 @@
171171
'longform_notetweets_inline_media_enabled': True,
172172
'responsive_web_enhance_cards_enabled': False
173173
}
174+
175+
TWEET_RESULT_BY_REST_ID_FEATURES = {
176+
'creator_subscriptions_tweet_preview_api_enabled': True,
177+
'communities_web_enable_tweet_community_results_fetch': True,
178+
'c9s_tweet_anatomy_moderator_badge_enabled': True,
179+
'articles_preview_enabled': True,
180+
'tweetypie_unmention_optimization_enabled': True,
181+
'responsive_web_edit_tweet_api_enabled': True,
182+
'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True,
183+
'view_counts_everywhere_api_enabled': True,
184+
'longform_notetweets_consumption_enabled': True,
185+
'responsive_web_twitter_article_tweet_consumption_enabled': True,
186+
'tweet_awards_web_tipping_enabled': False,
187+
'creator_subscriptions_quote_tweet_preview_enabled': False,
188+
'freedom_of_speech_not_reach_fetch_enabled': True,
189+
'standardized_nudges_misinfo': True,
190+
'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True,
191+
'rweb_video_timestamps_enabled': True,
192+
'longform_notetweets_rich_text_read_enabled': True,
193+
'longform_notetweets_inline_media_enabled': True,
194+
'rweb_tipjar_consumption_enabled': True,
195+
'responsive_web_graphql_exclude_directive_enabled': True,
196+
'verified_phone_label_enabled': False,
197+
'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
198+
'responsive_web_graphql_timeline_navigation_enabled': True,
199+
'responsive_web_enhance_cards_enabled': False
200+
}
201+
202+
USER_HIGHLIGHTS_TWEETS_FEATURES = {
203+
'rweb_tipjar_consumption_enabled': True,
204+
'responsive_web_graphql_exclude_directive_enabled': True,
205+
'verified_phone_label_enabled': False,
206+
'creator_subscriptions_tweet_preview_api_enabled': True,
207+
'responsive_web_graphql_timeline_navigation_enabled': True,
208+
'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
209+
'communities_web_enable_tweet_community_results_fetch': True,
210+
'c9s_tweet_anatomy_moderator_badge_enabled': True,
211+
'articles_preview_enabled': True,
212+
'tweetypie_unmention_optimization_enabled': True,
213+
'responsive_web_edit_tweet_api_enabled': True,
214+
'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True,
215+
'view_counts_everywhere_api_enabled': True,
216+
'longform_notetweets_consumption_enabled': True,
217+
'responsive_web_twitter_article_tweet_consumption_enabled': True,
218+
'tweet_awards_web_tipping_enabled': False,
219+
'creator_subscriptions_quote_tweet_preview_enabled': False,
220+
'freedom_of_speech_not_reach_fetch_enabled': True,
221+
'standardized_nudges_misinfo': True,
222+
'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': True,
223+
'rweb_video_timestamps_enabled': True,
224+
'longform_notetweets_rich_text_read_enabled': True,
225+
'longform_notetweets_inline_media_enabled': True,
226+
'responsive_web_enhance_cards_enabled': False
227+
}

twikit/guest/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .client import GuestClient
2+
from .tweet import Tweet
3+
from .user import User

0 commit comments

Comments
 (0)