tweepy-self 0.1.0__py3-none-any.whl → 1.0.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.
- tweepy_self-1.0.0.dist-info/METADATA +216 -0
- tweepy_self-1.0.0.dist-info/RECORD +15 -0
- twitter/__init__.py +26 -0
- twitter/account.py +98 -0
- twitter/base/__init__.py +7 -0
- twitter/base/client.py +20 -0
- twitter/base/session.py +56 -0
- twitter/client.py +1220 -0
- twitter/errors.py +145 -0
- twitter/models.py +64 -0
- twitter/utils/__init__.py +36 -0
- twitter/utils/file.py +41 -0
- twitter/utils/html.py +29 -0
- twitter/utils/other.py +24 -0
- tweepy-self/__init__.py +0 -0
- tweepy_self-0.1.0.dist-info/METADATA +0 -15
- tweepy_self-0.1.0.dist-info/RECORD +0 -4
- {tweepy_self-0.1.0.dist-info → tweepy_self-1.0.0.dist-info}/WHEEL +0 -0
twitter/client.py
ADDED
@@ -0,0 +1,1220 @@
|
|
1
|
+
from typing import Any, Literal
|
2
|
+
from time import time
|
3
|
+
import asyncio
|
4
|
+
import base64
|
5
|
+
import re
|
6
|
+
|
7
|
+
from curl_cffi import requests
|
8
|
+
from yarl import URL
|
9
|
+
|
10
|
+
from python3_capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
|
11
|
+
|
12
|
+
from .errors import (
|
13
|
+
TwitterException,
|
14
|
+
HTTPException,
|
15
|
+
BadRequest,
|
16
|
+
Unauthorized,
|
17
|
+
Forbidden,
|
18
|
+
NotFound,
|
19
|
+
RateLimited,
|
20
|
+
ServerError,
|
21
|
+
BadAccount,
|
22
|
+
BadToken,
|
23
|
+
Locked,
|
24
|
+
Suspended,
|
25
|
+
)
|
26
|
+
from .utils import to_json
|
27
|
+
from .base import BaseClient
|
28
|
+
from .account import Account, AccountStatus
|
29
|
+
from .models import UserData, Tweet
|
30
|
+
from .utils import remove_at_sign, parse_oauth_html, parse_unlock_html
|
31
|
+
|
32
|
+
|
33
|
+
class Client(BaseClient):
|
34
|
+
_BEARER_TOKEN = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'
|
35
|
+
_DEFAULT_HEADERS = {
|
36
|
+
'authority': 'twitter.com',
|
37
|
+
'origin': 'https://twitter.com',
|
38
|
+
'x-twitter-active-user': 'yes',
|
39
|
+
# 'x-twitter-auth-type': 'OAuth2Session',
|
40
|
+
'x-twitter-client-language': 'en',
|
41
|
+
}
|
42
|
+
_GRAPHQL_URL = 'https://twitter.com/i/api/graphql'
|
43
|
+
_ACTION_TO_QUERY_ID = {
|
44
|
+
'CreateRetweet': "ojPdsZsimiJrUGLR1sjUtA",
|
45
|
+
'FavoriteTweet': "lI07N6Otwv1PhnEgXILM7A",
|
46
|
+
'UnfavoriteTweet': "ZYKSe-w7KEslx3JhSIk5LA",
|
47
|
+
'CreateTweet': "SoVnbfCycZ7fERGCwpZkYA",
|
48
|
+
'TweetResultByRestId': "V3vfsYzNEyD9tsf4xoFRgw",
|
49
|
+
'ModerateTweet': "p'jF:GVqCjTcZol0xcBJjw",
|
50
|
+
'DeleteTweet': "VaenaVgh5q5ih7kvyVjgtg",
|
51
|
+
'UserTweets': "V1ze5q3ijDS1VeLwLY0m7g",
|
52
|
+
'TweetDetail': 'VWFGPVAGkZMGRKGe3GFFnA',
|
53
|
+
'ProfileSpotlightsQuery': '9zwVLJ48lmVUk8u_Gh9DmA',
|
54
|
+
'Following': 't-BPOrMIduGUJWO_LxcvNQ',
|
55
|
+
'Followers': '3yX7xr2hKjcZYnXt6cU6lQ',
|
56
|
+
'UserByScreenName': 'G3KGOASz96M-Qu0nwmGXNg',
|
57
|
+
'Viewer': 'W62NnYgkgziw9bwyoVht0g',
|
58
|
+
}
|
59
|
+
_CAPTCHA_URL = 'https://twitter.com/account/access'
|
60
|
+
_CAPTCHA_SITE_KEY = '0152B4EB-D2DC-460A-89A1-629838B529C9'
|
61
|
+
_CAPSOLVER_APP_ID = "6F895B2F-F454-44D1-8FE0-77ACAD3DBDC8"
|
62
|
+
|
63
|
+
@classmethod
|
64
|
+
def _action_to_url(cls, action: str) -> tuple[str, str]:
|
65
|
+
"""
|
66
|
+
:return: URL and Query ID
|
67
|
+
"""
|
68
|
+
query_id = cls._ACTION_TO_QUERY_ID[action]
|
69
|
+
url = f"{cls._GRAPHQL_URL}/{query_id}/{action}"
|
70
|
+
return url, query_id
|
71
|
+
|
72
|
+
def __init__(
|
73
|
+
self,
|
74
|
+
account: Account,
|
75
|
+
*,
|
76
|
+
wait_on_rate_limit: bool = True,
|
77
|
+
**session_kwargs,
|
78
|
+
):
|
79
|
+
super().__init__(**session_kwargs)
|
80
|
+
self.account = account
|
81
|
+
self.wait_on_rate_limit = wait_on_rate_limit
|
82
|
+
|
83
|
+
async def request(
|
84
|
+
self,
|
85
|
+
method,
|
86
|
+
url,
|
87
|
+
auth: bool = True,
|
88
|
+
bearer: bool = True,
|
89
|
+
**kwargs,
|
90
|
+
) -> tuple[requests.Response, Any]:
|
91
|
+
cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
|
92
|
+
headers = kwargs["headers"] = kwargs.get("headers") or {}
|
93
|
+
|
94
|
+
if bearer:
|
95
|
+
headers["authorization"] = self._BEARER_TOKEN
|
96
|
+
|
97
|
+
if auth:
|
98
|
+
if not self.account.auth_token:
|
99
|
+
raise ValueError("No auth_token. Login before")
|
100
|
+
|
101
|
+
cookies["auth_token"] = self.account.auth_token
|
102
|
+
if self.account.ct0:
|
103
|
+
cookies["ct0"] = self.account.ct0
|
104
|
+
headers["x-csrf-token"] = self.account.ct0
|
105
|
+
|
106
|
+
try:
|
107
|
+
response = await self._session.request(method, url, **kwargs)
|
108
|
+
except requests.errors.RequestsError as exc:
|
109
|
+
if exc.code == 35:
|
110
|
+
msg = "The IP address may have been blocked by Twitter. Blocked countries: Russia. " + str(exc)
|
111
|
+
raise requests.errors.RequestsError(msg, 35, exc.response)
|
112
|
+
raise
|
113
|
+
|
114
|
+
data = response.text
|
115
|
+
if response.headers['content-type'].startswith('application/json'):
|
116
|
+
data = response.json()
|
117
|
+
|
118
|
+
if response.status_code == 429:
|
119
|
+
if self.wait_on_rate_limit:
|
120
|
+
reset_time = int(response.headers["x-rate-limit-reset"])
|
121
|
+
sleep_time = reset_time - int(time()) + 1
|
122
|
+
if sleep_time > 0:
|
123
|
+
await asyncio.sleep(sleep_time)
|
124
|
+
return await self.request(method, url, auth, bearer, **kwargs)
|
125
|
+
raise RateLimited(response, data)
|
126
|
+
|
127
|
+
if response.status_code == 400:
|
128
|
+
raise BadRequest(response, data)
|
129
|
+
|
130
|
+
if response.status_code == 401:
|
131
|
+
exc = Unauthorized(response, data)
|
132
|
+
|
133
|
+
if 32 in exc.api_codes:
|
134
|
+
self.account.status = AccountStatus.BAD_TOKEN
|
135
|
+
raise BadToken(self.account)
|
136
|
+
|
137
|
+
raise exc
|
138
|
+
|
139
|
+
if response.status_code == 403:
|
140
|
+
exc = Forbidden(response, data)
|
141
|
+
|
142
|
+
if 353 in exc.api_codes and "ct0" in response.cookies:
|
143
|
+
self.account.ct0 = response.cookies["ct0"]
|
144
|
+
return await self.request(method, url, auth, bearer, **kwargs)
|
145
|
+
|
146
|
+
if 64 in exc.api_codes:
|
147
|
+
self.account.status = AccountStatus.SUSPENDED
|
148
|
+
raise Suspended(self.account)
|
149
|
+
|
150
|
+
if 326 in exc.api_codes:
|
151
|
+
self.account.status = AccountStatus.LOCKED
|
152
|
+
raise Locked(self.account)
|
153
|
+
|
154
|
+
raise exc
|
155
|
+
|
156
|
+
if response.status_code == 404:
|
157
|
+
raise NotFound(response, data)
|
158
|
+
|
159
|
+
if response.status_code >= 500:
|
160
|
+
raise ServerError(response, data)
|
161
|
+
|
162
|
+
if not 200 <= response.status_code < 300:
|
163
|
+
raise HTTPException(response, data)
|
164
|
+
|
165
|
+
if isinstance(data, dict) and "errors" in data:
|
166
|
+
exc = HTTPException(response, data)
|
167
|
+
|
168
|
+
if 141 in exc.api_codes:
|
169
|
+
self.account.status = AccountStatus.SUSPENDED
|
170
|
+
raise Suspended(self.account)
|
171
|
+
|
172
|
+
if 326 in exc.api_codes:
|
173
|
+
self.account.status = AccountStatus.LOCKED
|
174
|
+
raise Locked(self.account)
|
175
|
+
|
176
|
+
raise exc
|
177
|
+
|
178
|
+
self.account.status = AccountStatus.GOOD
|
179
|
+
return response, data
|
180
|
+
|
181
|
+
async def _request_oauth_2_auth_code(
|
182
|
+
self,
|
183
|
+
client_id: str,
|
184
|
+
code_challenge: str,
|
185
|
+
state: str,
|
186
|
+
redirect_uri: str,
|
187
|
+
code_challenge_method: str,
|
188
|
+
scope: str,
|
189
|
+
response_type: str,
|
190
|
+
) -> str:
|
191
|
+
url = "https://twitter.com/i/api/2/oauth2/authorize"
|
192
|
+
querystring = {
|
193
|
+
"client_id": client_id,
|
194
|
+
"code_challenge": code_challenge,
|
195
|
+
"code_challenge_method": code_challenge_method,
|
196
|
+
"state": state,
|
197
|
+
"scope": scope,
|
198
|
+
"response_type": response_type,
|
199
|
+
"redirect_uri": redirect_uri,
|
200
|
+
}
|
201
|
+
response, response_json = await self.request("GET", url, params=querystring)
|
202
|
+
auth_code = response_json["auth_code"]
|
203
|
+
return auth_code
|
204
|
+
|
205
|
+
async def _confirm_oauth_2(self, auth_code: str):
|
206
|
+
data = {
|
207
|
+
'approval': 'true',
|
208
|
+
'code': auth_code,
|
209
|
+
}
|
210
|
+
headers = {'content-type': 'application/x-www-form-urlencoded'}
|
211
|
+
await self.request("POST", 'https://twitter.com/i/api/2/oauth2/authorize', headers=headers, data=data)
|
212
|
+
|
213
|
+
async def oauth_2(
|
214
|
+
self,
|
215
|
+
client_id: str,
|
216
|
+
code_challenge: str,
|
217
|
+
state: str,
|
218
|
+
redirect_uri: str,
|
219
|
+
code_challenge_method: str,
|
220
|
+
scope: str,
|
221
|
+
response_type: str,
|
222
|
+
):
|
223
|
+
"""
|
224
|
+
Запрашивает код авторизации для OAuth 2.0 авторизации.
|
225
|
+
|
226
|
+
Привязка (бинд, линк) приложения.
|
227
|
+
|
228
|
+
:param client_id: Идентификатор клиента, используемый для OAuth.
|
229
|
+
:param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
|
230
|
+
:param state: Уникальная строка состояния для предотвращения CSRF-атак.
|
231
|
+
:param redirect_uri: URI перенаправления, на который будет отправлен ответ.
|
232
|
+
:param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
|
233
|
+
:param scope: Строка областей доступа, запрашиваемых у пользователя.
|
234
|
+
:param response_type: Тип ответа, который ожидается от сервера авторизации.
|
235
|
+
:return: Код авторизации (привязки).
|
236
|
+
"""
|
237
|
+
auth_code = await self._request_oauth_2_auth_code(
|
238
|
+
client_id, code_challenge, state, redirect_uri, code_challenge_method, scope, response_type,
|
239
|
+
)
|
240
|
+
await self._confirm_oauth_2(auth_code)
|
241
|
+
return auth_code
|
242
|
+
|
243
|
+
async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
|
244
|
+
"""
|
245
|
+
|
246
|
+
:return: Response: html страница привязки приложения (аутентификации) старого типа.
|
247
|
+
"""
|
248
|
+
url = "https://api.twitter.com/oauth/authenticate"
|
249
|
+
oauth_params["oauth_token"] = oauth_token
|
250
|
+
response, _ = await self.request("GET", url, params=oauth_params)
|
251
|
+
|
252
|
+
if response.status_code == 403:
|
253
|
+
raise ValueError("The request token (oauth_token) for this page is invalid."
|
254
|
+
" It may have already been used, or expired because it is too old.")
|
255
|
+
|
256
|
+
return response
|
257
|
+
|
258
|
+
async def _confirm_oauth(
|
259
|
+
self,
|
260
|
+
oauth_token: str,
|
261
|
+
authenticity_token: str,
|
262
|
+
redirect_after_login_url: str,
|
263
|
+
) -> requests.Response:
|
264
|
+
url = "https://api.twitter.com/oauth/authorize"
|
265
|
+
params = {
|
266
|
+
"redirect_after_login": redirect_after_login_url,
|
267
|
+
"authenticity_token": authenticity_token,
|
268
|
+
"oauth_token": oauth_token,
|
269
|
+
}
|
270
|
+
response, _ = await self.request("POST", url, data=params)
|
271
|
+
return response
|
272
|
+
|
273
|
+
async def oauth(self, oauth_token: str, **oauth_params) -> tuple[str, str]:
|
274
|
+
"""
|
275
|
+
:return: authenticity_token, redirect_url
|
276
|
+
"""
|
277
|
+
response = await self._oauth(oauth_token, **oauth_params)
|
278
|
+
authenticity_token, redirect_url, redirect_after_login_url = parse_oauth_html(response.text)
|
279
|
+
|
280
|
+
# Первая привязка требует подтверждения
|
281
|
+
if redirect_after_login_url:
|
282
|
+
response = await self._confirm_oauth(oauth_token, authenticity_token, redirect_after_login_url)
|
283
|
+
authenticity_token, redirect_url, redirect_after_login_url = parse_oauth_html(response.text)
|
284
|
+
|
285
|
+
return authenticity_token, redirect_url
|
286
|
+
|
287
|
+
async def request_username(self):
|
288
|
+
url = "https://twitter.com/i/api/1.1/account/settings.json"
|
289
|
+
response, response_json = await self.request("POST", url)
|
290
|
+
self.account.username = response_json["screen_name"]
|
291
|
+
|
292
|
+
async def _request_user_data(self, username: str) -> UserData:
|
293
|
+
url, query_id = self._action_to_url("UserByScreenName")
|
294
|
+
username = remove_at_sign(username)
|
295
|
+
variables = {
|
296
|
+
"screen_name": username,
|
297
|
+
"withSafetyModeUserFields": True,
|
298
|
+
}
|
299
|
+
features = {
|
300
|
+
"hidden_profile_likes_enabled": True,
|
301
|
+
"hidden_profile_subscriptions_enabled": True,
|
302
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
303
|
+
"verified_phone_label_enabled": False,
|
304
|
+
"subscriptions_verification_info_is_identity_verified_enabled": True,
|
305
|
+
"subscriptions_verification_info_verified_since_enabled": True,
|
306
|
+
"highlights_tweets_tab_ui_enabled": True,
|
307
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
308
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
309
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
310
|
+
}
|
311
|
+
field_toggles = {
|
312
|
+
"withAuxiliaryUserLabels": False,
|
313
|
+
}
|
314
|
+
params = {
|
315
|
+
"variables": to_json(variables),
|
316
|
+
"features": to_json(features),
|
317
|
+
"fieldToggles": to_json(field_toggles),
|
318
|
+
}
|
319
|
+
response, response_json = await self.request("GET", url, params=params)
|
320
|
+
user_data = UserData.from_raw_user_data(response_json["data"]["user"]["result"])
|
321
|
+
|
322
|
+
if self.account.username == user_data.username:
|
323
|
+
self.account.id = user_data.id
|
324
|
+
self.account.name = user_data.name
|
325
|
+
|
326
|
+
return user_data
|
327
|
+
|
328
|
+
async def request_user_data(self, username: str = None) -> UserData:
|
329
|
+
if username:
|
330
|
+
return await self._request_user_data(username)
|
331
|
+
else:
|
332
|
+
if not self.account.username:
|
333
|
+
await self.request_username()
|
334
|
+
return await self._request_user_data(self.account.username)
|
335
|
+
|
336
|
+
async def upload_image(self, image: bytes) -> int:
|
337
|
+
"""
|
338
|
+
Upload image as bytes.
|
339
|
+
|
340
|
+
:return: Media ID
|
341
|
+
"""
|
342
|
+
url = "https://upload.twitter.com/1.1/media/upload.json"
|
343
|
+
|
344
|
+
data = {"media_data": base64.b64encode(image)}
|
345
|
+
response, response_json = await self.request("POST", url, data=data)
|
346
|
+
media_id = response_json["media_id"]
|
347
|
+
return media_id
|
348
|
+
|
349
|
+
async def _follow_action(self, action: str, user_id: int | str) -> bool:
|
350
|
+
url = f"https://twitter.com/i/api/1.1/friendships/{action}.json"
|
351
|
+
params = {
|
352
|
+
'include_profile_interstitial_type': '1',
|
353
|
+
'include_blocking': '1',
|
354
|
+
'include_blocked_by': '1',
|
355
|
+
'include_followed_by': '1',
|
356
|
+
'include_want_retweets': '1',
|
357
|
+
'include_mute_edge': '1',
|
358
|
+
'include_can_dm': '1',
|
359
|
+
'include_can_media_tag': '1',
|
360
|
+
'include_ext_has_nft_avatar': '1',
|
361
|
+
'include_ext_is_blue_verified': '1',
|
362
|
+
'include_ext_verified_type': '1',
|
363
|
+
'include_ext_profile_image_shape': '1',
|
364
|
+
'skip_status': '1',
|
365
|
+
'user_id': user_id,
|
366
|
+
}
|
367
|
+
headers = {
|
368
|
+
'content-type': 'application/x-www-form-urlencoded',
|
369
|
+
}
|
370
|
+
response, response_json = await self.request("POST", url, params=params, headers=headers)
|
371
|
+
return bool(response_json)
|
372
|
+
|
373
|
+
async def follow(self, user_id: str | int) -> bool:
|
374
|
+
return await self._follow_action("create", user_id)
|
375
|
+
|
376
|
+
async def unfollow(self, user_id: str | int) -> bool:
|
377
|
+
return await self._follow_action("destroy", user_id)
|
378
|
+
|
379
|
+
async def _interact_with_tweet(self, action: str, tweet_id: int) -> dict:
|
380
|
+
url, query_id = self._action_to_url(action)
|
381
|
+
json_payload = {
|
382
|
+
'variables': {
|
383
|
+
'tweet_id': tweet_id,
|
384
|
+
'dark_request': False
|
385
|
+
},
|
386
|
+
'queryId': query_id
|
387
|
+
}
|
388
|
+
response, response_json = await self.request("POST", url, json=json_payload)
|
389
|
+
return response_json
|
390
|
+
|
391
|
+
async def repost(self, tweet_id: int) -> int:
|
392
|
+
"""
|
393
|
+
Repost (retweet)
|
394
|
+
|
395
|
+
:return: Tweet ID
|
396
|
+
"""
|
397
|
+
response_json = await self._interact_with_tweet('CreateRetweet', tweet_id)
|
398
|
+
retweet_id = int(response_json['data']['create_retweet']['retweet_results']['result']['rest_id'])
|
399
|
+
return retweet_id
|
400
|
+
|
401
|
+
async def like(self, tweet_id: int) -> bool:
|
402
|
+
response_json = await self._interact_with_tweet('FavoriteTweet', tweet_id)
|
403
|
+
is_liked = response_json['data']['favorite_tweet'] == 'Done'
|
404
|
+
return is_liked
|
405
|
+
|
406
|
+
async def unlike(self, tweet_id: int) -> dict:
|
407
|
+
response_json = await self._interact_with_tweet('UnfavoriteTweet', tweet_id)
|
408
|
+
is_unliked = 'data' in response_json and response_json['data']['unfavorite_tweet'] == 'Done'
|
409
|
+
return is_unliked
|
410
|
+
|
411
|
+
async def delete_tweet(self, tweet_id: int | str) -> bool:
|
412
|
+
url, query_id = self._action_to_url('DeleteTweet')
|
413
|
+
json_payload = {
|
414
|
+
'variables': {
|
415
|
+
'tweet_id': tweet_id,
|
416
|
+
'dark_request': False,
|
417
|
+
},
|
418
|
+
'queryId': query_id,
|
419
|
+
}
|
420
|
+
response, response_json = await self.request("POST", url, json=json_payload)
|
421
|
+
is_deleted = "data" in response_json and "delete_tweet" in response_json["data"]
|
422
|
+
return is_deleted
|
423
|
+
|
424
|
+
async def pin_tweet(self, tweet_id: str | int) -> bool:
|
425
|
+
url = 'https://api.twitter.com/1.1/account/pin_tweet.json'
|
426
|
+
data = {
|
427
|
+
'tweet_mode': 'extended',
|
428
|
+
'id': str(tweet_id),
|
429
|
+
}
|
430
|
+
headers = {
|
431
|
+
'content-type': 'application/x-www-form-urlencoded',
|
432
|
+
}
|
433
|
+
response, response_json = await self.request("POST", url, headers=headers, data=data)
|
434
|
+
is_pinned = bool(response_json["pinned_tweets"])
|
435
|
+
return is_pinned
|
436
|
+
|
437
|
+
async def _tweet(
|
438
|
+
self,
|
439
|
+
text: str = None,
|
440
|
+
*,
|
441
|
+
media_id: int | str = None,
|
442
|
+
tweet_id_to_reply: str | int = None,
|
443
|
+
attachment_url: str = None,
|
444
|
+
) -> int:
|
445
|
+
url, query_id = self._action_to_url('CreateTweet')
|
446
|
+
payload = {
|
447
|
+
'variables': {
|
448
|
+
'tweet_text': text if text is not None else "",
|
449
|
+
'dark_request': False,
|
450
|
+
'media': {
|
451
|
+
'media_entities': [],
|
452
|
+
'possibly_sensitive': False},
|
453
|
+
'semantic_annotation_ids': [],
|
454
|
+
},
|
455
|
+
'features': {
|
456
|
+
'tweetypie_unmention_optimization_enabled': True,
|
457
|
+
'responsive_web_edit_tweet_api_enabled': True,
|
458
|
+
'graphql_is_translatable_rweb_tweet_is_translatable_enabled': True,
|
459
|
+
'view_counts_everywhere_api_enabled': True,
|
460
|
+
'longform_notetweets_consumption_enabled': True,
|
461
|
+
'tweet_awards_web_tipping_enabled': False,
|
462
|
+
'longform_notetweets_rich_text_read_enabled': True,
|
463
|
+
'longform_notetweets_inline_media_enabled': True,
|
464
|
+
'responsive_web_graphql_exclude_directive_enabled': True,
|
465
|
+
'verified_phone_label_enabled': False,
|
466
|
+
'freedom_of_speech_not_reach_fetch_enabled': True,
|
467
|
+
'standardized_nudges_misinfo': True,
|
468
|
+
'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': False,
|
469
|
+
'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
|
470
|
+
'responsive_web_graphql_timeline_navigation_enabled': True,
|
471
|
+
'responsive_web_enhance_cards_enabled': False,
|
472
|
+
'responsive_web_twitter_article_tweet_consumption_enabled': False,
|
473
|
+
'responsive_web_media_download_video_enabled': False
|
474
|
+
},
|
475
|
+
'queryId': query_id,
|
476
|
+
}
|
477
|
+
if attachment_url:
|
478
|
+
payload['variables']['attachment_url'] = attachment_url
|
479
|
+
if tweet_id_to_reply:
|
480
|
+
payload['variables']['reply'] = {
|
481
|
+
'in_reply_to_tweet_id': str(tweet_id_to_reply),
|
482
|
+
'exclude_reply_user_ids': [],
|
483
|
+
}
|
484
|
+
if media_id:
|
485
|
+
payload['variables']['media']['media_entities'].append({'media_id': str(media_id), 'tagged_users': []})
|
486
|
+
|
487
|
+
response, response_json = await self.request("POST", url, json=payload)
|
488
|
+
tweet_id = response_json['data']['create_tweet']['tweet_results']['result']['rest_id']
|
489
|
+
return tweet_id
|
490
|
+
|
491
|
+
async def tweet(self, text: str, *, media_id: int | str = None) -> int:
|
492
|
+
"""
|
493
|
+
:return: Tweet ID
|
494
|
+
"""
|
495
|
+
return await self._tweet(text, media_id=media_id)
|
496
|
+
|
497
|
+
async def reply(self, tweet_id: str | int, text: str, *, media_id: int | str = None) -> int:
|
498
|
+
"""
|
499
|
+
:return: Tweet ID
|
500
|
+
"""
|
501
|
+
return await self._tweet(text, media_id=media_id, tweet_id_to_reply=tweet_id)
|
502
|
+
|
503
|
+
async def quote(self, tweet_url: str, text: str, *, media_id: int | str = None) -> int:
|
504
|
+
"""
|
505
|
+
:return: Tweet ID
|
506
|
+
"""
|
507
|
+
return await self._tweet(text, media_id=media_id, attachment_url=tweet_url)
|
508
|
+
|
509
|
+
async def vote(self, tweet_id: int | str, card_id: int | str, choice_number: int) -> dict:
|
510
|
+
"""
|
511
|
+
:return: Raw vote information
|
512
|
+
"""
|
513
|
+
url = "https://caps.twitter.com/v2/capi/passthrough/1"
|
514
|
+
params = {
|
515
|
+
"twitter:string:card_uri": f"card://{card_id}",
|
516
|
+
"twitter:long:original_tweet_id": str(tweet_id),
|
517
|
+
"twitter:string:response_card_name": "poll2choice_text_only",
|
518
|
+
"twitter:string:cards_platform": "Web-12",
|
519
|
+
"twitter:string:selected_choice": str(choice_number),
|
520
|
+
}
|
521
|
+
response, response_json = await self.request("POST", url, params=params)
|
522
|
+
return response_json
|
523
|
+
|
524
|
+
async def _request_users(self, action: str, user_id: int | str, count: int) -> list[UserData]:
|
525
|
+
url, query_id = self._action_to_url(action)
|
526
|
+
variables = {
|
527
|
+
'userId': str(user_id),
|
528
|
+
'count': count,
|
529
|
+
'includePromotedContent': False,
|
530
|
+
}
|
531
|
+
features = {
|
532
|
+
"rweb_lists_timeline_redesign_enabled": True,
|
533
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
534
|
+
"verified_phone_label_enabled": False,
|
535
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
536
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
537
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
538
|
+
"tweetypie_unmention_optimization_enabled": True,
|
539
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
540
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
541
|
+
"view_counts_everywhere_api_enabled": True,
|
542
|
+
"longform_notetweets_consumption_enabled": True,
|
543
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": False,
|
544
|
+
"tweet_awards_web_tipping_enabled": False,
|
545
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
546
|
+
"standardized_nudges_misinfo": True,
|
547
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
548
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
549
|
+
"longform_notetweets_inline_media_enabled": True,
|
550
|
+
"responsive_web_media_download_video_enabled": False,
|
551
|
+
"responsive_web_enhance_cards_enabled": False
|
552
|
+
}
|
553
|
+
params = {
|
554
|
+
'variables': to_json(variables),
|
555
|
+
'features': to_json(features),
|
556
|
+
}
|
557
|
+
response, response_json = await self.request("GET", url, params=params)
|
558
|
+
|
559
|
+
users = []
|
560
|
+
if 'result' in response_json['data']['user']:
|
561
|
+
entries = response_json['data']['user']['result']['timeline']['timeline']['instructions'][-1]['entries']
|
562
|
+
for entry in entries:
|
563
|
+
if entry['entryId'].startswith('user'):
|
564
|
+
user_data_dict = entry["content"]["itemContent"]["user_results"]["result"]
|
565
|
+
users.append(UserData.from_raw_user_data(user_data_dict))
|
566
|
+
return users
|
567
|
+
|
568
|
+
async def request_followers(self, user_id: int | str = None, count: int = 10) -> list[UserData]:
|
569
|
+
"""
|
570
|
+
:param user_id: Текущий пользователь, если не передан ID иного пользователя.
|
571
|
+
:param count: Количество подписчиков.
|
572
|
+
"""
|
573
|
+
if user_id:
|
574
|
+
return await self._request_users('Followers', user_id, count)
|
575
|
+
else:
|
576
|
+
if not self.account.id:
|
577
|
+
await self.request_user_data()
|
578
|
+
return await self._request_users('Followers', self.account.id, count)
|
579
|
+
|
580
|
+
async def request_followings(self, user_id: int | str = None, count: int = 10) -> list[UserData]:
|
581
|
+
"""
|
582
|
+
:param user_id: Текущий пользователь, если не передан ID иного пользователя.
|
583
|
+
:param count: Количество подписчиков.
|
584
|
+
"""
|
585
|
+
if user_id:
|
586
|
+
return await self._request_users('Following', user_id, count)
|
587
|
+
else:
|
588
|
+
if not self.account.id:
|
589
|
+
await self.request_user_data()
|
590
|
+
return await self._request_users('Following', self.account.id, count)
|
591
|
+
|
592
|
+
async def _request_tweet_data(self, tweet_id: int) -> dict:
|
593
|
+
action = 'TweetDetail'
|
594
|
+
url, query_id = self._action_to_url(action)
|
595
|
+
variables = {
|
596
|
+
"focalTweetId": str(tweet_id),
|
597
|
+
"with_rux_injections": False,
|
598
|
+
"includePromotedContent": True,
|
599
|
+
"withCommunity": True,
|
600
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
601
|
+
"withBirdwatchNotes": True,
|
602
|
+
"withVoice": True,
|
603
|
+
"withV2Timeline": True,
|
604
|
+
}
|
605
|
+
features = {
|
606
|
+
"rweb_lists_timeline_redesign_enabled": True,
|
607
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
608
|
+
"verified_phone_label_enabled": False,
|
609
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
610
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
611
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
612
|
+
"tweetypie_unmention_optimization_enabled": True,
|
613
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
614
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
615
|
+
"view_counts_everywhere_api_enabled": True,
|
616
|
+
"longform_notetweets_consumption_enabled": True,
|
617
|
+
"tweet_awards_web_tipping_enabled": False,
|
618
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
619
|
+
"standardized_nudges_misinfo": True,
|
620
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
621
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
622
|
+
"longform_notetweets_inline_media_enabled": True,
|
623
|
+
"responsive_web_enhance_cards_enabled": False,
|
624
|
+
}
|
625
|
+
params = {
|
626
|
+
'variables': to_json(variables),
|
627
|
+
'features': to_json(features),
|
628
|
+
}
|
629
|
+
response, response_json = await self.request("GET", url, params=params)
|
630
|
+
return response_json
|
631
|
+
|
632
|
+
async def _update_profile_image(self, type: Literal["banner", "image"], media_id: str | int) -> str:
|
633
|
+
"""
|
634
|
+
:return: Image URL
|
635
|
+
"""
|
636
|
+
url = f"https://api.twitter.com/1.1/account/update_profile_{type}.json"
|
637
|
+
params = {
|
638
|
+
'media_id': str(media_id),
|
639
|
+
'include_profile_interstitial_type': '1',
|
640
|
+
'include_blocking': '1',
|
641
|
+
'include_blocked_by': '1',
|
642
|
+
'include_followed_by': '1',
|
643
|
+
'include_want_retweets': '1',
|
644
|
+
'include_mute_edge': '1',
|
645
|
+
'include_can_dm': '1',
|
646
|
+
'include_can_media_tag': '1',
|
647
|
+
'include_ext_has_nft_avatar': '1',
|
648
|
+
'include_ext_is_blue_verified': '1',
|
649
|
+
'include_ext_verified_type': '1',
|
650
|
+
'include_ext_profile_image_shape': '1',
|
651
|
+
'skip_status': '1',
|
652
|
+
'return_user': 'true',
|
653
|
+
}
|
654
|
+
response, response_json = await self.request("POST", url, params=params)
|
655
|
+
image_url = response_json[f"profile_{type}_url"]
|
656
|
+
return image_url
|
657
|
+
|
658
|
+
async def update_profile_avatar(self, media_id: int | str) -> str:
|
659
|
+
"""
|
660
|
+
:return: Image URL
|
661
|
+
"""
|
662
|
+
return await self._update_profile_image("image", media_id)
|
663
|
+
|
664
|
+
async def update_profile_banner(self, media_id: int | str) -> str:
|
665
|
+
"""
|
666
|
+
:return: Image URL
|
667
|
+
"""
|
668
|
+
return await self._update_profile_image("banner", media_id)
|
669
|
+
|
670
|
+
async def change_username(self, username: str) -> bool:
|
671
|
+
url = "https://twitter.com/i/api/1.1/account/settings.json"
|
672
|
+
data = {"screen_name": username}
|
673
|
+
response, response_json = await self.request("POST", url, data=data)
|
674
|
+
new_username = response_json["screen_name"]
|
675
|
+
is_changed = new_username == username
|
676
|
+
self.account.username = new_username
|
677
|
+
return is_changed
|
678
|
+
|
679
|
+
async def change_password(self, password: str) -> bool:
|
680
|
+
"""
|
681
|
+
После изменения пароля обновляется auth_token!
|
682
|
+
"""
|
683
|
+
if not self.account.password:
|
684
|
+
raise ValueError(f"Specify the current password before changing it")
|
685
|
+
|
686
|
+
url = "https://twitter.com/i/api/i/account/change_password.json"
|
687
|
+
data = {
|
688
|
+
"current_password": self.account.password,
|
689
|
+
"password": password,
|
690
|
+
"password_confirmation": password
|
691
|
+
}
|
692
|
+
response, response_json = await self.request("POST", url, data=data)
|
693
|
+
is_changed = response_json["status"] == "ok"
|
694
|
+
auth_token = response.cookies.get("auth_token", domain=".twitter.com")
|
695
|
+
self.account.auth_token = auth_token
|
696
|
+
self.account.password = password
|
697
|
+
return is_changed
|
698
|
+
|
699
|
+
async def update_profile(
|
700
|
+
self,
|
701
|
+
name: str = None,
|
702
|
+
description: str = None,
|
703
|
+
location: str = None,
|
704
|
+
website: str = None,
|
705
|
+
) -> bool:
|
706
|
+
"""
|
707
|
+
Locks an account!
|
708
|
+
"""
|
709
|
+
if name is None and description is None:
|
710
|
+
raise ValueError("Specify at least one param")
|
711
|
+
|
712
|
+
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
713
|
+
headers = {"content-type": "application/x-www-form-urlencoded"}
|
714
|
+
# Создаем словарь data, включая в него только те ключи, для которых значения не равны None
|
715
|
+
data = {k: v for k, v in [
|
716
|
+
("name", name),
|
717
|
+
("description", description),
|
718
|
+
("location", location),
|
719
|
+
("url", website),
|
720
|
+
] if v is not None}
|
721
|
+
response, response_json = await self.request("POST", url, headers=headers, data=data)
|
722
|
+
# Проверяем, что все переданные параметры соответствуют полученным
|
723
|
+
is_updated = all(response_json.get(key) == value for key, value in data.items() if key != "url")
|
724
|
+
if website: is_updated &= URL(website) == URL(response_json["entities"]["url"]["urls"][0]["expanded_url"])
|
725
|
+
await self.establish_status() # Изменение данных профиля часто замораживает аккаунт
|
726
|
+
return is_updated
|
727
|
+
|
728
|
+
async def establish_status(self):
|
729
|
+
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
730
|
+
try:
|
731
|
+
await self.request("POST", url)
|
732
|
+
except BadAccount:
|
733
|
+
pass
|
734
|
+
|
735
|
+
async def update_birthdate(
|
736
|
+
self,
|
737
|
+
day: int,
|
738
|
+
month: int,
|
739
|
+
year: int,
|
740
|
+
visibility: Literal["self", "mutualfollow"] = "self",
|
741
|
+
year_visibility: Literal["self"] = "self",
|
742
|
+
) -> bool:
|
743
|
+
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
744
|
+
headers = {"content-type": "application/x-www-form-urlencoded"}
|
745
|
+
data = {
|
746
|
+
"birthdate_day": day,
|
747
|
+
"birthdate_month": month,
|
748
|
+
"birthdate_year": year,
|
749
|
+
"birthdate_visibility": visibility,
|
750
|
+
"birthdate_year_visibility": year_visibility,
|
751
|
+
}
|
752
|
+
response, response_json = await self.request("POST", url, headers=headers, data=data)
|
753
|
+
birthdate_data = response_json["extended_profile"]["birthdate"]
|
754
|
+
is_updated = all((
|
755
|
+
birthdate_data["day"] == day,
|
756
|
+
birthdate_data["month"] == month,
|
757
|
+
birthdate_data["year"] == year,
|
758
|
+
birthdate_data["visibility"] == visibility,
|
759
|
+
birthdate_data["year_visibility"] == year_visibility,
|
760
|
+
))
|
761
|
+
return is_updated
|
762
|
+
|
763
|
+
async def send_message(self, user_id: int | str, text: str) -> dict:
|
764
|
+
"""
|
765
|
+
:return: Event data
|
766
|
+
"""
|
767
|
+
url = "https://api.twitter.com/1.1/direct_messages/events/new.json"
|
768
|
+
payload = {"event": {
|
769
|
+
"type": "message_create",
|
770
|
+
"message_create": {
|
771
|
+
"target": {
|
772
|
+
"recipient_id": user_id
|
773
|
+
}, "message_data": {
|
774
|
+
"text": text}
|
775
|
+
}
|
776
|
+
}}
|
777
|
+
response, response_json = await self.request("POST", url, json=payload)
|
778
|
+
event_data = response_json["event"]
|
779
|
+
return event_data
|
780
|
+
|
781
|
+
async def request_messages(self) -> list[dict]:
|
782
|
+
"""
|
783
|
+
:return: Messages data
|
784
|
+
"""
|
785
|
+
url = 'https://twitter.com/i/api/1.1/dm/inbox_initial_state.json'
|
786
|
+
params = {
|
787
|
+
'nsfw_filtering_enabled': 'false',
|
788
|
+
'filter_low_quality': 'false',
|
789
|
+
'include_quality': 'all',
|
790
|
+
'include_profile_interstitial_type': '1',
|
791
|
+
'include_blocking': '1',
|
792
|
+
'include_blocked_by': '1',
|
793
|
+
'include_followed_by': '1',
|
794
|
+
'include_want_retweets': '1',
|
795
|
+
'include_mute_edge': '1',
|
796
|
+
'include_can_dm': '1',
|
797
|
+
'include_can_media_tag': '1',
|
798
|
+
'include_ext_has_nft_avatar': '1',
|
799
|
+
'include_ext_is_blue_verified': '1',
|
800
|
+
'include_ext_verified_type': '1',
|
801
|
+
'include_ext_profile_image_shape': '1',
|
802
|
+
'skip_status': '1',
|
803
|
+
'dm_secret_conversations_enabled': 'false',
|
804
|
+
'krs_registration_enabled': 'true',
|
805
|
+
'cards_platform': 'Web-12',
|
806
|
+
'include_cards': '1',
|
807
|
+
'include_ext_alt_text': 'true',
|
808
|
+
'include_ext_limited_action_results': 'true',
|
809
|
+
'include_quote_count': 'true',
|
810
|
+
'include_reply_count': '1',
|
811
|
+
'tweet_mode': 'extended',
|
812
|
+
'include_ext_views': 'true',
|
813
|
+
'dm_users': 'true',
|
814
|
+
'include_groups': 'true',
|
815
|
+
'include_inbox_timelines': 'true',
|
816
|
+
'include_ext_media_color': 'true',
|
817
|
+
'supports_reactions': 'true',
|
818
|
+
'include_ext_edit_control': 'true',
|
819
|
+
'include_ext_business_affiliations_label': 'true',
|
820
|
+
'ext': 'mediaColor,altText,mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,superFollowMetadata,unmentionInfo,editControl',
|
821
|
+
}
|
822
|
+
response, response_json = await self.request("GET", url, params=params)
|
823
|
+
messages = [entry["message"] for entry in response_json["inbox_initial_state"]["entries"] if "message" in entry]
|
824
|
+
return messages
|
825
|
+
|
826
|
+
async def request_tweets(self, user_id: str | int, count: int = 20) -> list[Tweet]:
|
827
|
+
url, query_id = self._action_to_url("UserTweets")
|
828
|
+
variables = {
|
829
|
+
"userId": str(user_id),
|
830
|
+
"count": count,
|
831
|
+
"includePromotedContent": True,
|
832
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
833
|
+
"withVoice": True,
|
834
|
+
"withV2Timeline": True
|
835
|
+
}
|
836
|
+
features = {
|
837
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
838
|
+
"verified_phone_label_enabled": False,
|
839
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
840
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
841
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
842
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
843
|
+
"tweetypie_unmention_optimization_enabled": True,
|
844
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
845
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
846
|
+
"view_counts_everywhere_api_enabled": True,
|
847
|
+
"longform_notetweets_consumption_enabled": True,
|
848
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": False,
|
849
|
+
"tweet_awards_web_tipping_enabled": False,
|
850
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
851
|
+
"standardized_nudges_misinfo": True,
|
852
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
853
|
+
"rweb_video_timestamps_enabled": True,
|
854
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
855
|
+
"longform_notetweets_inline_media_enabled": True,
|
856
|
+
"responsive_web_media_download_video_enabled": False,
|
857
|
+
"responsive_web_enhance_cards_enabled": False
|
858
|
+
}
|
859
|
+
params = {
|
860
|
+
'variables': to_json(variables),
|
861
|
+
'features': to_json(features)
|
862
|
+
}
|
863
|
+
response, response_json = await self.request("GET", url, params=params)
|
864
|
+
|
865
|
+
tweets = []
|
866
|
+
for instruction in response_json['data']['user']['result']['timeline_v2']['timeline']['instructions']:
|
867
|
+
if instruction['type'] == 'TimelineAddEntries':
|
868
|
+
for entry in instruction['entries']:
|
869
|
+
if entry['entryId'].startswith('tweet'):
|
870
|
+
tweet_data = entry["content"]['itemContent']['tweet_results']['result']
|
871
|
+
tweets.append(Tweet.from_raw_data(tweet_data))
|
872
|
+
return tweets
|
873
|
+
|
874
|
+
async def _confirm_unlock(
|
875
|
+
self,
|
876
|
+
authenticity_token: str,
|
877
|
+
assignment_token: str,
|
878
|
+
verification_string: str = None,
|
879
|
+
) -> tuple[requests.Response, str]:
|
880
|
+
payload = {
|
881
|
+
"authenticity_token": authenticity_token,
|
882
|
+
"assignment_token": assignment_token,
|
883
|
+
"lang": "en",
|
884
|
+
"flow": "",
|
885
|
+
}
|
886
|
+
if verification_string:
|
887
|
+
payload["verification_string"] = verification_string
|
888
|
+
payload["language_code"] = "en"
|
889
|
+
|
890
|
+
return await self.request("POST", self._CAPTCHA_URL, data=payload, bearer=False)
|
891
|
+
|
892
|
+
async def unlock(
|
893
|
+
self,
|
894
|
+
capsolver_api_key: str,
|
895
|
+
attempts: int = 4):
|
896
|
+
await self.establish_status()
|
897
|
+
if not self.account.status == "LOCKED":
|
898
|
+
return
|
899
|
+
|
900
|
+
response, html = await self.request("GET", self._CAPTCHA_URL, bearer=False)
|
901
|
+
authenticity_token, assignment_token, needs_unlock = parse_unlock_html(html)
|
902
|
+
attempt = 1
|
903
|
+
|
904
|
+
funcaptcha = {
|
905
|
+
"api_key": capsolver_api_key,
|
906
|
+
"websiteURL": self._CAPTCHA_URL,
|
907
|
+
"websitePublicKey": self._CAPTCHA_SITE_KEY,
|
908
|
+
"appId": self._CAPSOLVER_APP_ID,
|
909
|
+
}
|
910
|
+
if self._session.proxy is not None:
|
911
|
+
funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTask
|
912
|
+
funcaptcha["proxyType"] = self._session.proxy.protocol
|
913
|
+
funcaptcha["proxyAddress"] = self._session.proxy.host
|
914
|
+
funcaptcha["proxyPort"] = self._session.proxy.port
|
915
|
+
funcaptcha["proxyLogin"] = self._session.proxy.login
|
916
|
+
funcaptcha["proxyPassword"] = self._session.proxy.password
|
917
|
+
else:
|
918
|
+
funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
|
919
|
+
|
920
|
+
while needs_unlock:
|
921
|
+
solution = await FunCaptcha(**funcaptcha).aio_captcha_handler()
|
922
|
+
token = solution.solution["token"]
|
923
|
+
response, html = await self._confirm_unlock(authenticity_token, assignment_token,
|
924
|
+
verification_string=token)
|
925
|
+
|
926
|
+
if attempt > attempts or response.url == "https://twitter.com/?lang=en":
|
927
|
+
await self.establish_status()
|
928
|
+
return
|
929
|
+
|
930
|
+
authenticity_token, assignment_token, needs_unlock = parse_unlock_html(html)
|
931
|
+
attempt += 1
|
932
|
+
|
933
|
+
async def _task(self, **kwargs):
|
934
|
+
"""
|
935
|
+
:return: flow_token, subtasks
|
936
|
+
"""
|
937
|
+
url = 'https://api.twitter.com/1.1/onboarding/task.json'
|
938
|
+
response, response_json = await self.request("POST", url, **kwargs)
|
939
|
+
return response_json["flow_token"], response_json["subtasks"]
|
940
|
+
|
941
|
+
async def _request_login_tasks(self):
|
942
|
+
"""
|
943
|
+
:return: flow_token, subtask_ids
|
944
|
+
"""
|
945
|
+
params = {
|
946
|
+
"flow_name": "login",
|
947
|
+
}
|
948
|
+
payload = {
|
949
|
+
"input_flow_data": {
|
950
|
+
"flow_context": {
|
951
|
+
"debug_overrides": {},
|
952
|
+
"start_location": {"location": "splash_screen"}
|
953
|
+
}
|
954
|
+
},
|
955
|
+
"subtask_versions": {
|
956
|
+
"action_list": 2, "alert_dialog": 1, "app_download_cta": 1, "check_logged_in_account": 1,
|
957
|
+
"choice_selection": 3, "contacts_live_sync_permission_prompt": 0, "cta": 7,
|
958
|
+
"email_verification": 2, "end_flow": 1, "enter_date": 1, "enter_email": 2,
|
959
|
+
"enter_password": 5, "enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5,
|
960
|
+
"enter_username": 2, "generic_urt": 3, "in_app_notification": 1, "interest_picker": 3,
|
961
|
+
"js_instrumentation": 1, "menu_dialog": 1, "notifications_permission_prompt": 2,
|
962
|
+
"open_account": 2, "open_home_timeline": 1, "open_link": 1, "phone_verification": 4,
|
963
|
+
"privacy_options": 1, "security_key": 3, "select_avatar": 4, "select_banner": 2,
|
964
|
+
"settings_list": 7, "show_code": 1, "sign_up": 2, "sign_up_review": 4, "tweet_selection_urt": 1,
|
965
|
+
"update_users": 1, "upload_media": 1, "user_recommendations_list": 4,
|
966
|
+
"user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1
|
967
|
+
}
|
968
|
+
}
|
969
|
+
return await self._task(params=params, json=payload, auth=False)
|
970
|
+
|
971
|
+
async def _send_task(self, flow_token: str, subtask_inputs: list[dict], **kwargs):
|
972
|
+
payload = kwargs["json"] = kwargs.get("json") or {}
|
973
|
+
payload.update({
|
974
|
+
"flow_token": flow_token,
|
975
|
+
"subtask_inputs": subtask_inputs,
|
976
|
+
})
|
977
|
+
return await self._task(**kwargs)
|
978
|
+
|
979
|
+
async def _finish_task(self, flow_token):
|
980
|
+
payload = {
|
981
|
+
"flow_token": flow_token,
|
982
|
+
"subtask_inputs": [],
|
983
|
+
}
|
984
|
+
return await self._task(json=payload)
|
985
|
+
|
986
|
+
async def _login_enter_user_identifier(self, flow_token):
|
987
|
+
subtask_inputs = [
|
988
|
+
{
|
989
|
+
"subtask_id": "LoginEnterUserIdentifierSSO",
|
990
|
+
"settings_list": {
|
991
|
+
"setting_responses": [
|
992
|
+
{
|
993
|
+
"key": "user_identifier",
|
994
|
+
"response_data": {"text_data": {"result": self.account.email or self.account.username}}
|
995
|
+
}
|
996
|
+
],
|
997
|
+
"link": "next_link"
|
998
|
+
}
|
999
|
+
}
|
1000
|
+
]
|
1001
|
+
return await self._send_task(flow_token, subtask_inputs, auth=False)
|
1002
|
+
|
1003
|
+
async def _login_enter_password(self, flow_token):
|
1004
|
+
subtask_inputs = [
|
1005
|
+
{
|
1006
|
+
"subtask_id": "LoginEnterPassword",
|
1007
|
+
"enter_password": {
|
1008
|
+
"password": self.account.password,
|
1009
|
+
"link": "next_link"
|
1010
|
+
}
|
1011
|
+
}
|
1012
|
+
]
|
1013
|
+
return await self._send_task(flow_token, subtask_inputs, auth=False)
|
1014
|
+
|
1015
|
+
async def _account_duplication_check(self, flow_token):
|
1016
|
+
subtask_inputs = [
|
1017
|
+
{
|
1018
|
+
"subtask_id": "AccountDuplicationCheck",
|
1019
|
+
"check_logged_in_account": {
|
1020
|
+
"link": "AccountDuplicationCheck_false"
|
1021
|
+
}
|
1022
|
+
}
|
1023
|
+
]
|
1024
|
+
return await self._send_task(flow_token, subtask_inputs, auth=False)
|
1025
|
+
|
1026
|
+
async def _login_two_factor_auth_challenge(self, flow_token):
|
1027
|
+
if not self.account.key2fa:
|
1028
|
+
raise TwitterException(f"Failed to login. Task id: LoginTwoFactorAuthChallenge")
|
1029
|
+
|
1030
|
+
subtask_inputs = [
|
1031
|
+
{
|
1032
|
+
"subtask_id": "LoginTwoFactorAuthChallenge",
|
1033
|
+
"enter_text": {"text": self.account.get_2fa_code(), "link": "next_link"}
|
1034
|
+
}
|
1035
|
+
]
|
1036
|
+
return await self._send_task(flow_token, subtask_inputs, auth=False)
|
1037
|
+
|
1038
|
+
async def _viewer(self):
|
1039
|
+
"""
|
1040
|
+
Здесь нужно забрать ct0
|
1041
|
+
:return:
|
1042
|
+
"""
|
1043
|
+
url, query_id = self._action_to_url("Viewer")
|
1044
|
+
features = {
|
1045
|
+
'responsive_web_graphql_exclude_directive_enabled': True,
|
1046
|
+
'verified_phone_label_enabled': False,
|
1047
|
+
'creator_subscriptions_tweet_preview_api_enabled': True,
|
1048
|
+
'responsive_web_graphql_skip_user_profile_image_extensions_enabled': False,
|
1049
|
+
'responsive_web_graphql_timeline_navigation_enabled': True,
|
1050
|
+
}
|
1051
|
+
field_toggles = {
|
1052
|
+
'isDelegate': False,
|
1053
|
+
'withAuxiliaryUserLabels': False,
|
1054
|
+
}
|
1055
|
+
variables = {"withCommunitiesMemberships": True}
|
1056
|
+
params = {
|
1057
|
+
"features": to_json(features),
|
1058
|
+
"fieldToggles": to_json(field_toggles),
|
1059
|
+
"variables": to_json(variables),
|
1060
|
+
}
|
1061
|
+
return self.request("GET", url, params=params)
|
1062
|
+
|
1063
|
+
async def _request_guest_token(self) -> str:
|
1064
|
+
"""
|
1065
|
+
Помимо запроса guest_token также устанавливает в сессию guest_id cookie
|
1066
|
+
|
1067
|
+
:return: guest_token
|
1068
|
+
"""
|
1069
|
+
url = 'https://twitter.com'
|
1070
|
+
response = await self._session.request("GET", url)
|
1071
|
+
guest_token = re.search(r'gt\s?=\s?\d+', response.text)[0].split('=')[1]
|
1072
|
+
return guest_token
|
1073
|
+
|
1074
|
+
async def _login(self):
|
1075
|
+
guest_token = await self._request_guest_token()
|
1076
|
+
self._session.cookies["gt"] = guest_token
|
1077
|
+
self._session.headers["X-Guest-Token"] = guest_token
|
1078
|
+
|
1079
|
+
flow_token, subtasks = await self._request_login_tasks()
|
1080
|
+
for _ in range(2):
|
1081
|
+
flow_token, subtasks = await self._login_enter_user_identifier(flow_token)
|
1082
|
+
flow_token, subtasks = await self._login_enter_password(flow_token)
|
1083
|
+
flow_token, subtasks = await self._account_duplication_check(flow_token)
|
1084
|
+
|
1085
|
+
subtask_ids = [subtask["subtask_id"] for subtask in subtasks]
|
1086
|
+
|
1087
|
+
# TODO Обработчик
|
1088
|
+
if "LoginAcid" in subtask_ids:
|
1089
|
+
raise TwitterException(f"Failed to login: email verification!")
|
1090
|
+
|
1091
|
+
if "LoginTwoFactorAuthChallenge" in subtask_ids:
|
1092
|
+
flow_token, subtasks = await self._login_two_factor_auth_challenge(flow_token)
|
1093
|
+
|
1094
|
+
# TODO Возможно, стоит добавить отслеживание этих параметров прямо в request
|
1095
|
+
self.account.auth_token = self._session.cookies["auth_token"]
|
1096
|
+
self.account.ct0 = self._session.cookies["ct0"]
|
1097
|
+
|
1098
|
+
await self._finish_task(flow_token)
|
1099
|
+
|
1100
|
+
async def login(self):
|
1101
|
+
if self.account.auth_token:
|
1102
|
+
await self.establish_status()
|
1103
|
+
if self.account.status != "BAD_TOKEN":
|
1104
|
+
return
|
1105
|
+
|
1106
|
+
if not self.account.email and not self.account.username:
|
1107
|
+
raise ValueError("No email or username")
|
1108
|
+
|
1109
|
+
if not self.account.password:
|
1110
|
+
raise ValueError("No password")
|
1111
|
+
|
1112
|
+
await self._login()
|
1113
|
+
await self.establish_status()
|
1114
|
+
|
1115
|
+
async def is_enabled_2fa(self):
|
1116
|
+
if not self.account.id:
|
1117
|
+
await self.request_user_data()
|
1118
|
+
|
1119
|
+
url = f'https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2'
|
1120
|
+
response, response_json = await self.request("GET", url)
|
1121
|
+
return 'Totp' in [i['twoFactorType'] for i in response_json['methods']]
|
1122
|
+
|
1123
|
+
async def _request_2fa_tasks(self):
|
1124
|
+
"""
|
1125
|
+
:return: flow_token, subtask_ids
|
1126
|
+
"""
|
1127
|
+
params = {
|
1128
|
+
"flow_name": "two-factor-auth-app-enrollment",
|
1129
|
+
}
|
1130
|
+
payload = {
|
1131
|
+
"input_flow_data": {"flow_context": {"debug_overrides": {}, "start_location": {"location": "settings"}}},
|
1132
|
+
"subtask_versions": {"action_list": 2, "alert_dialog": 1, "app_download_cta": 1,
|
1133
|
+
"check_logged_in_account": 1, "choice_selection": 3,
|
1134
|
+
"contacts_live_sync_permission_prompt": 0, "cta": 7, "email_verification": 2,
|
1135
|
+
"end_flow": 1, "enter_date": 1, "enter_email": 2,
|
1136
|
+
"enter_password": 5, "enter_phone": 2, "enter_recaptcha": 1, "enter_text": 5,
|
1137
|
+
"enter_username": 2, "generic_urt": 3,
|
1138
|
+
"in_app_notification": 1, "interest_picker": 3, "js_instrumentation": 1,
|
1139
|
+
"menu_dialog": 1, "notifications_permission_prompt": 2,
|
1140
|
+
"open_account": 2, "open_home_timeline": 1, "open_link": 1, "phone_verification": 4,
|
1141
|
+
"privacy_options": 1, "security_key": 3,
|
1142
|
+
"select_avatar": 4, "select_banner": 2, "settings_list": 7, "show_code": 1,
|
1143
|
+
"sign_up": 2, "sign_up_review": 4, "tweet_selection_urt": 1,
|
1144
|
+
"update_users": 1, "upload_media": 1, "user_recommendations_list": 4,
|
1145
|
+
"user_recommendations_urt": 1, "wait_spinner": 3, "web_modal": 1
|
1146
|
+
}
|
1147
|
+
}
|
1148
|
+
return await self._task(params=params, json=payload)
|
1149
|
+
|
1150
|
+
async def _two_factor_enrollment_verify_password_subtask(self, flow_token: str):
|
1151
|
+
subtask_inputs = [
|
1152
|
+
{
|
1153
|
+
"subtask_id": "TwoFactorEnrollmentVerifyPasswordSubtask",
|
1154
|
+
"enter_password": {"password": self.account.password, "link": "next_link"}
|
1155
|
+
}
|
1156
|
+
]
|
1157
|
+
return await self._send_task(flow_token, subtask_inputs)
|
1158
|
+
|
1159
|
+
async def _two_factor_enrollment_authentication_app_begin_subtask(self, flow_token: str):
|
1160
|
+
subtask_inputs = [
|
1161
|
+
{
|
1162
|
+
"subtask_id": "TwoFactorEnrollmentAuthenticationAppBeginSubtask",
|
1163
|
+
"action_list": {"link": "next_link"}
|
1164
|
+
}
|
1165
|
+
]
|
1166
|
+
return await self._send_task(flow_token, subtask_inputs)
|
1167
|
+
|
1168
|
+
async def _two_factor_enrollment_authentication_app_plain_code_subtask(self, flow_token: str):
|
1169
|
+
subtask_inputs = [
|
1170
|
+
{
|
1171
|
+
"subtask_id": "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask",
|
1172
|
+
"show_code": {"link": "next_link"}
|
1173
|
+
},
|
1174
|
+
{
|
1175
|
+
"subtask_id": "TwoFactorEnrollmentAuthenticationAppEnterCodeSubtask",
|
1176
|
+
"enter_text": {"text": self.account.get_2fa_code(), "link": "next_link"}
|
1177
|
+
}
|
1178
|
+
]
|
1179
|
+
return await self._send_task(flow_token, subtask_inputs)
|
1180
|
+
|
1181
|
+
async def _finish_2fa_task(self, flow_token: str):
|
1182
|
+
subtask_inputs = [
|
1183
|
+
{
|
1184
|
+
"subtask_id": "TwoFactorEnrollmentAuthenticationAppCompleteSubtask",
|
1185
|
+
"cta": {"link": "finish_link"}
|
1186
|
+
}
|
1187
|
+
]
|
1188
|
+
return await self._send_task(flow_token, subtask_inputs)
|
1189
|
+
|
1190
|
+
async def _enable_2fa(self):
|
1191
|
+
flow_token, subtasks = await self._request_2fa_tasks()
|
1192
|
+
flow_token, subtasks = await self._two_factor_enrollment_verify_password_subtask(flow_token)
|
1193
|
+
flow_token, subtasks = await self._two_factor_enrollment_authentication_app_begin_subtask(flow_token)
|
1194
|
+
|
1195
|
+
for subtask in subtasks:
|
1196
|
+
if subtask["subtask_id"] == 'TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask':
|
1197
|
+
self.account.key2fa = subtask['show_code']['code']
|
1198
|
+
break
|
1199
|
+
|
1200
|
+
flow_token, subtasks = await self._two_factor_enrollment_authentication_app_plain_code_subtask(flow_token)
|
1201
|
+
|
1202
|
+
for subtask in subtasks:
|
1203
|
+
if subtask["subtask_id"] == 'TwoFactorEnrollmentAuthenticationAppCompleteSubtask':
|
1204
|
+
result = re.search(r'\n[a-z0-9]{12}\n', subtask['cta']['secondary_text']['text'])
|
1205
|
+
backup_code = result[0].strip() if result else None
|
1206
|
+
self.account.backup_code = backup_code
|
1207
|
+
break
|
1208
|
+
|
1209
|
+
await self._finish_2fa_task(flow_token)
|
1210
|
+
|
1211
|
+
async def enable_2fa(self):
|
1212
|
+
if not self.account.password:
|
1213
|
+
raise ValueError("Password is required for this action")
|
1214
|
+
|
1215
|
+
if await self.is_enabled_2fa():
|
1216
|
+
return
|
1217
|
+
|
1218
|
+
# TODO Перед началом работы вызываем request_user_data, чтоб убедиться что нет других ошибок
|
1219
|
+
await self.request_user_data()
|
1220
|
+
await self._enable_2fa()
|