tweepy-self 1.6.3__py3-none-any.whl → 1.10.0b9__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.10.0b9.dist-info/METADATA +303 -0
- tweepy_self-1.10.0b9.dist-info/RECORD +23 -0
- twitter/__init__.py +15 -5
- twitter/_capsolver/__init__.py +0 -0
- twitter/_capsolver/core/__init__.py +0 -0
- twitter/_capsolver/core/base.py +227 -0
- twitter/_capsolver/core/config.py +36 -0
- twitter/_capsolver/core/enum.py +66 -0
- twitter/_capsolver/core/serializer.py +85 -0
- twitter/_capsolver/fun_captcha.py +260 -0
- twitter/account.py +17 -13
- twitter/base/__init__.py +2 -2
- twitter/base/client.py +4 -4
- twitter/client.py +744 -382
- twitter/errors.py +14 -7
- twitter/models.py +147 -50
- twitter/utils/__init__.py +2 -0
- twitter/utils/html.py +6 -2
- twitter/utils/other.py +13 -0
- tweepy_self-1.6.3.dist-info/METADATA +0 -218
- tweepy_self-1.6.3.dist-info/RECORD +0 -16
- {tweepy_self-1.6.3.dist-info → tweepy_self-1.10.0b9.dist-info}/WHEEL +0 -0
twitter/client.py
CHANGED
@@ -1,13 +1,15 @@
|
|
1
|
-
from typing import Any, Literal
|
1
|
+
from typing import Any, Literal, Iterable
|
2
2
|
from time import time
|
3
3
|
import asyncio
|
4
4
|
import base64
|
5
|
+
import json
|
5
6
|
import re
|
6
7
|
|
8
|
+
from loguru import logger
|
7
9
|
from curl_cffi import requests
|
8
10
|
from yarl import URL
|
9
11
|
|
10
|
-
from
|
12
|
+
from ._capsolver.fun_captcha import FunCaptcha, FunCaptchaTypeEnm
|
11
13
|
|
12
14
|
from .errors import (
|
13
15
|
TwitterException,
|
@@ -25,20 +27,23 @@ from .errors import (
|
|
25
27
|
ConsentLocked,
|
26
28
|
Suspended,
|
27
29
|
)
|
28
|
-
from .utils import to_json
|
29
|
-
from .base import
|
30
|
+
from .utils import to_json
|
31
|
+
from .base import BaseHTTPClient
|
30
32
|
from .account import Account, AccountStatus
|
31
|
-
from .models import
|
32
|
-
from .utils import
|
33
|
+
from .models import User, Tweet, Media, Subtask
|
34
|
+
from .utils import (
|
35
|
+
parse_oauth_html,
|
36
|
+
parse_unlock_html,
|
37
|
+
tweets_data_from_instructions,
|
38
|
+
)
|
33
39
|
|
34
40
|
|
35
|
-
class Client(
|
36
|
-
_BEARER_TOKEN = "
|
41
|
+
class Client(BaseHTTPClient):
|
42
|
+
_BEARER_TOKEN = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
|
37
43
|
_DEFAULT_HEADERS = {
|
38
44
|
"authority": "twitter.com",
|
39
45
|
"origin": "https://twitter.com",
|
40
46
|
"x-twitter-active-user": "yes",
|
41
|
-
# 'x-twitter-auth-type': 'OAuth2Session',
|
42
47
|
"x-twitter-client-language": "en",
|
43
48
|
}
|
44
49
|
_GRAPHQL_URL = "https://twitter.com/i/api/graphql"
|
@@ -46,7 +51,7 @@ class Client(BaseClient):
|
|
46
51
|
"CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
|
47
52
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
48
53
|
"UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
|
49
|
-
"CreateTweet": "
|
54
|
+
"CreateTweet": "v0en1yVV-Ybeek8ClmXwYw",
|
50
55
|
"TweetResultByRestId": "V3vfsYzNEyD9tsf4xoFRgw",
|
51
56
|
"ModerateTweet": "p'jF:GVqCjTcZol0xcBJjw",
|
52
57
|
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
|
@@ -56,6 +61,7 @@ class Client(BaseClient):
|
|
56
61
|
"Following": "t-BPOrMIduGUJWO_LxcvNQ",
|
57
62
|
"Followers": "3yX7xr2hKjcZYnXt6cU6lQ",
|
58
63
|
"UserByScreenName": "G3KGOASz96M-Qu0nwmGXNg",
|
64
|
+
"UsersByRestIds": "itEhGywpgX9b3GJCzOtSrA",
|
59
65
|
"Viewer": "W62NnYgkgziw9bwyoVht0g",
|
60
66
|
}
|
61
67
|
_CAPTCHA_URL = "https://twitter.com/account/access"
|
@@ -76,7 +82,9 @@ class Client(BaseClient):
|
|
76
82
|
*,
|
77
83
|
wait_on_rate_limit: bool = True,
|
78
84
|
capsolver_api_key: str = None,
|
79
|
-
max_unlock_attempts: int =
|
85
|
+
max_unlock_attempts: int = 5,
|
86
|
+
auto_relogin: bool = True,
|
87
|
+
update_account_info_on_startup: bool = True,
|
80
88
|
**session_kwargs,
|
81
89
|
):
|
82
90
|
super().__init__(**session_kwargs)
|
@@ -84,20 +92,29 @@ class Client(BaseClient):
|
|
84
92
|
self.wait_on_rate_limit = wait_on_rate_limit
|
85
93
|
self.capsolver_api_key = capsolver_api_key
|
86
94
|
self.max_unlock_attempts = max_unlock_attempts
|
95
|
+
self.auto_relogin = auto_relogin
|
96
|
+
self._update_account_info_on_startup = update_account_info_on_startup
|
87
97
|
|
88
|
-
async def
|
98
|
+
async def __aenter__(self):
|
99
|
+
await self.on_startup()
|
100
|
+
return await super().__aenter__()
|
101
|
+
|
102
|
+
async def _request(
|
89
103
|
self,
|
90
104
|
method,
|
91
105
|
url,
|
106
|
+
*,
|
92
107
|
auth: bool = True,
|
93
108
|
bearer: bool = True,
|
109
|
+
wait_on_rate_limit: bool = None,
|
94
110
|
**kwargs,
|
95
111
|
) -> tuple[requests.Response, Any]:
|
96
112
|
cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
|
97
113
|
headers = kwargs["headers"] = kwargs.get("headers") or {}
|
98
114
|
|
99
115
|
if bearer:
|
100
|
-
headers["authorization"] = self._BEARER_TOKEN
|
116
|
+
headers["authorization"] = f"Bearer {self._BEARER_TOKEN}"
|
117
|
+
# headers["x-twitter-auth-type"] = "OAuth2Session"
|
101
118
|
|
102
119
|
if auth:
|
103
120
|
if not self.account.auth_token:
|
@@ -108,6 +125,14 @@ class Client(BaseClient):
|
|
108
125
|
cookies["ct0"] = self.account.ct0
|
109
126
|
headers["x-csrf-token"] = self.account.ct0
|
110
127
|
|
128
|
+
# fmt: off
|
129
|
+
log_message = (f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
130
|
+
f" ==> Request {method} {url}")
|
131
|
+
if kwargs.get('data'): log_message += f"\nRequest data: {kwargs.get('data')}"
|
132
|
+
if kwargs.get('json'): log_message += f"\nRequest data: {kwargs.get('json')}"
|
133
|
+
logger.debug(log_message)
|
134
|
+
# fmt: on
|
135
|
+
|
111
136
|
try:
|
112
137
|
response = await self._session.request(method, url, **kwargs)
|
113
138
|
except requests.errors.RequestsError as exc:
|
@@ -120,17 +145,52 @@ class Client(BaseClient):
|
|
120
145
|
raise
|
121
146
|
|
122
147
|
data = response.text
|
123
|
-
|
148
|
+
# fmt: off
|
149
|
+
logger.debug(f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
150
|
+
f" <== Response {method} {url}"
|
151
|
+
f"\nStatus code: {response.status_code}"
|
152
|
+
f"\nResponse data: {data}")
|
153
|
+
# fmt: on
|
154
|
+
|
155
|
+
if ct0 := self._session.cookies.get("ct0", domain=".twitter.com"):
|
156
|
+
self.account.ct0 = ct0
|
157
|
+
|
158
|
+
auth_token = self._session.cookies.get("auth_token")
|
159
|
+
if auth_token and auth_token != self.account.auth_token:
|
160
|
+
self.account.auth_token = auth_token
|
161
|
+
logger.warning(
|
162
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
163
|
+
f" Requested new auth_token!"
|
164
|
+
)
|
165
|
+
|
166
|
+
try:
|
124
167
|
data = response.json()
|
168
|
+
except json.decoder.JSONDecodeError:
|
169
|
+
pass
|
125
170
|
|
126
|
-
if response.status_code
|
127
|
-
if
|
128
|
-
|
129
|
-
|
130
|
-
if
|
131
|
-
|
132
|
-
|
133
|
-
|
171
|
+
if 300 > response.status_code >= 200:
|
172
|
+
if isinstance(data, dict) and "errors" in data:
|
173
|
+
exc = HTTPException(response, data)
|
174
|
+
|
175
|
+
if 141 in exc.api_codes:
|
176
|
+
self.account.status = AccountStatus.SUSPENDED
|
177
|
+
raise Suspended(exc, self.account)
|
178
|
+
|
179
|
+
if 326 in exc.api_codes:
|
180
|
+
for error_data in exc.api_errors:
|
181
|
+
if (
|
182
|
+
error_data.get("code") == 326
|
183
|
+
and error_data.get("bounce_location")
|
184
|
+
== "/i/flow/consent_flow"
|
185
|
+
):
|
186
|
+
self.account.status = AccountStatus.CONSENT_LOCKED
|
187
|
+
raise ConsentLocked(exc, self.account)
|
188
|
+
|
189
|
+
self.account.status = AccountStatus.LOCKED
|
190
|
+
raise Locked(exc, self.account)
|
191
|
+
raise exc
|
192
|
+
|
193
|
+
return response, data
|
134
194
|
|
135
195
|
if response.status_code == 400:
|
136
196
|
raise BadRequest(response, data)
|
@@ -147,10 +207,6 @@ class Client(BaseClient):
|
|
147
207
|
if response.status_code == 403:
|
148
208
|
exc = Forbidden(response, data)
|
149
209
|
|
150
|
-
if 353 in exc.api_codes and "ct0" in response.cookies:
|
151
|
-
self.account.ct0 = response.cookies["ct0"]
|
152
|
-
return await self.request(method, url, auth, bearer, **kwargs)
|
153
|
-
|
154
210
|
if 64 in exc.api_codes:
|
155
211
|
self.account.status = AccountStatus.SUSPENDED
|
156
212
|
raise Suspended(exc, self.account)
|
@@ -165,52 +221,83 @@ class Client(BaseClient):
|
|
165
221
|
raise ConsentLocked(exc, self.account)
|
166
222
|
|
167
223
|
self.account.status = AccountStatus.LOCKED
|
168
|
-
|
169
|
-
raise Locked(exc, self.account)
|
170
|
-
|
171
|
-
await self.unlock()
|
172
|
-
return await self.request(method, url, auth, bearer, **kwargs)
|
224
|
+
raise Locked(exc, self.account)
|
173
225
|
|
174
226
|
raise exc
|
175
227
|
|
176
228
|
if response.status_code == 404:
|
177
229
|
raise NotFound(response, data)
|
178
230
|
|
231
|
+
if response.status_code == 429:
|
232
|
+
if wait_on_rate_limit is None:
|
233
|
+
wait_on_rate_limit = self.wait_on_rate_limit
|
234
|
+
if not wait_on_rate_limit:
|
235
|
+
raise RateLimited(response, data)
|
236
|
+
|
237
|
+
reset_time = int(response.headers["x-rate-limit-reset"])
|
238
|
+
sleep_time = reset_time - int(time()) + 1
|
239
|
+
if sleep_time > 0:
|
240
|
+
logger.warning(
|
241
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
242
|
+
f"Rate limited! Sleep time: {sleep_time} sec."
|
243
|
+
)
|
244
|
+
await asyncio.sleep(sleep_time)
|
245
|
+
return await self._request(
|
246
|
+
method,
|
247
|
+
url,
|
248
|
+
auth=auth,
|
249
|
+
bearer=bearer,
|
250
|
+
wait_on_rate_limit=wait_on_rate_limit,
|
251
|
+
**kwargs,
|
252
|
+
)
|
253
|
+
|
179
254
|
if response.status_code >= 500:
|
180
255
|
raise ServerError(response, data)
|
181
256
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
257
|
+
async def request(
|
258
|
+
self,
|
259
|
+
method,
|
260
|
+
url,
|
261
|
+
*,
|
262
|
+
auto_unlock: bool = True,
|
263
|
+
auto_relogin: bool = None,
|
264
|
+
**kwargs,
|
265
|
+
) -> tuple[requests.Response, Any]:
|
266
|
+
try:
|
267
|
+
return await self._request(method, url, **kwargs)
|
187
268
|
|
188
|
-
|
189
|
-
|
190
|
-
raise
|
269
|
+
except Locked:
|
270
|
+
if not self.capsolver_api_key or not auto_unlock:
|
271
|
+
raise
|
191
272
|
|
192
|
-
|
193
|
-
|
194
|
-
if (
|
195
|
-
error_data.get("code") == 326
|
196
|
-
and error_data.get("bounce_location") == "/i/flow/consent_flow"
|
197
|
-
):
|
198
|
-
self.account.status = AccountStatus.CONSENT_LOCKED
|
199
|
-
raise ConsentLocked(exc, self.account)
|
273
|
+
await self.unlock()
|
274
|
+
return await self._request(method, url, **kwargs)
|
200
275
|
|
201
|
-
|
202
|
-
|
203
|
-
|
276
|
+
except BadToken:
|
277
|
+
if auto_relogin is None:
|
278
|
+
auto_relogin = self.auto_relogin
|
279
|
+
if (
|
280
|
+
not auto_relogin
|
281
|
+
or not self.account.password
|
282
|
+
or not (self.account.email or self.account.username)
|
283
|
+
):
|
284
|
+
raise
|
204
285
|
|
205
|
-
|
206
|
-
|
286
|
+
await self.relogin()
|
287
|
+
return await self._request(method, url, **kwargs)
|
207
288
|
|
208
|
-
|
289
|
+
except Forbidden as exc:
|
290
|
+
if 353 in exc.api_codes and "ct0" in exc.response.cookies:
|
291
|
+
return await self.request(method, url, **kwargs)
|
292
|
+
else:
|
293
|
+
raise
|
209
294
|
|
210
|
-
|
211
|
-
|
295
|
+
async def on_startup(self):
|
296
|
+
if self._update_account_info_on_startup:
|
297
|
+
await self.update_account_info()
|
298
|
+
await self.establish_status()
|
212
299
|
|
213
|
-
async def
|
300
|
+
async def _request_oauth2_auth_code(
|
214
301
|
self,
|
215
302
|
client_id: str,
|
216
303
|
code_challenge: str,
|
@@ -234,7 +321,7 @@ class Client(BaseClient):
|
|
234
321
|
auth_code = response_json["auth_code"]
|
235
322
|
return auth_code
|
236
323
|
|
237
|
-
async def
|
324
|
+
async def _confirm_oauth2(self, auth_code: str):
|
238
325
|
data = {
|
239
326
|
"approval": "true",
|
240
327
|
"code": auth_code,
|
@@ -247,7 +334,7 @@ class Client(BaseClient):
|
|
247
334
|
data=data,
|
248
335
|
)
|
249
336
|
|
250
|
-
async def
|
337
|
+
async def oauth2(
|
251
338
|
self,
|
252
339
|
client_id: str,
|
253
340
|
code_challenge: str,
|
@@ -263,15 +350,13 @@ class Client(BaseClient):
|
|
263
350
|
Привязка (бинд, линк) приложения.
|
264
351
|
|
265
352
|
:param client_id: Идентификатор клиента, используемый для OAuth.
|
266
|
-
:param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
|
267
353
|
:param state: Уникальная строка состояния для предотвращения CSRF-атак.
|
268
354
|
:param redirect_uri: URI перенаправления, на который будет отправлен ответ.
|
269
|
-
:param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
|
270
355
|
:param scope: Строка областей доступа, запрашиваемых у пользователя.
|
271
356
|
:param response_type: Тип ответа, который ожидается от сервера авторизации.
|
272
357
|
:return: Код авторизации (привязки).
|
273
358
|
"""
|
274
|
-
auth_code = await self.
|
359
|
+
auth_code = await self._request_oauth2_auth_code(
|
275
360
|
client_id,
|
276
361
|
code_challenge,
|
277
362
|
state,
|
@@ -280,7 +365,7 @@ class Client(BaseClient):
|
|
280
365
|
scope,
|
281
366
|
response_type,
|
282
367
|
)
|
283
|
-
await self.
|
368
|
+
await self._confirm_oauth2(auth_code)
|
284
369
|
return auth_code
|
285
370
|
|
286
371
|
async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
|
@@ -335,14 +420,13 @@ class Client(BaseClient):
|
|
335
420
|
|
336
421
|
return authenticity_token, redirect_url
|
337
422
|
|
338
|
-
async def
|
423
|
+
async def _update_account_username(self):
|
339
424
|
url = "https://twitter.com/i/api/1.1/account/settings.json"
|
340
425
|
response, response_json = await self.request("POST", url)
|
341
426
|
self.account.username = response_json["screen_name"]
|
342
427
|
|
343
|
-
async def
|
428
|
+
async def _request_user_by_username(self, username: str) -> User | None:
|
344
429
|
url, query_id = self._action_to_url("UserByScreenName")
|
345
|
-
username = remove_at_sign(username)
|
346
430
|
variables = {
|
347
431
|
"screen_name": username,
|
348
432
|
"withSafetyModeUserFields": True,
|
@@ -367,48 +451,99 @@ class Client(BaseClient):
|
|
367
451
|
"features": to_json(features),
|
368
452
|
"fieldToggles": to_json(field_toggles),
|
369
453
|
}
|
370
|
-
response,
|
371
|
-
|
454
|
+
response, data = await self.request("GET", url, params=params)
|
455
|
+
if not data["data"]:
|
456
|
+
return None
|
457
|
+
return User.from_raw_data(data["data"]["user"]["result"])
|
372
458
|
|
373
|
-
|
374
|
-
|
375
|
-
|
459
|
+
async def request_user_by_username(self, username: str) -> User | Account | None:
|
460
|
+
"""
|
461
|
+
:param username: Имя пользователя без знака `@`
|
462
|
+
:return: Пользователь, если существует, иначе None. Или собственный аккаунт, если совпадает имя пользователя.
|
463
|
+
"""
|
464
|
+
if not self.account.username:
|
465
|
+
await self.update_account_info()
|
376
466
|
|
377
|
-
|
467
|
+
user = await self._request_user_by_username(username)
|
378
468
|
|
379
|
-
|
380
|
-
|
381
|
-
return
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
469
|
+
if user and user.username == self.account.username:
|
470
|
+
self.account.update(**user.model_dump())
|
471
|
+
return self.account
|
472
|
+
|
473
|
+
return user
|
474
|
+
|
475
|
+
async def _request_users_by_ids(
|
476
|
+
self, user_ids: Iterable[str | int]
|
477
|
+
) -> dict[int : User | Account]:
|
478
|
+
url, query_id = self._action_to_url("UsersByRestIds")
|
479
|
+
variables = {"userIds": list({str(user_id) for user_id in user_ids})}
|
480
|
+
features = {
|
481
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
482
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
483
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
484
|
+
"verified_phone_label_enabled": False,
|
485
|
+
}
|
486
|
+
query = {"variables": variables, "features": features}
|
487
|
+
response, data = await self.request("GET", url, params=query)
|
488
|
+
|
489
|
+
users = {}
|
490
|
+
for user_data in data["data"]["users"]:
|
491
|
+
user_data = user_data["result"]
|
492
|
+
user = User.from_raw_data(user_data)
|
493
|
+
users[user.id] = user
|
494
|
+
if user.id == self.account.id:
|
495
|
+
users[self.account.id] = self.account
|
496
|
+
return users
|
497
|
+
|
498
|
+
async def request_user_by_id(self, user_id: int | str) -> User | Account | None:
|
499
|
+
"""
|
500
|
+
:param user_id: ID пользователя
|
501
|
+
:return: Пользователь, если существует, иначе None. Или собственный аккаунт, если совпадает ID.
|
502
|
+
"""
|
503
|
+
if not self.account.id:
|
504
|
+
await self.update_account_info()
|
505
|
+
|
506
|
+
users = await self._request_users_by_ids((user_id,))
|
507
|
+
user = users[user_id]
|
508
|
+
return user
|
509
|
+
|
510
|
+
async def request_users_by_ids(
|
511
|
+
self, user_ids: Iterable[str | int]
|
512
|
+
) -> dict[int : User | Account]:
|
513
|
+
"""
|
514
|
+
:param user_ids: ID пользователей
|
515
|
+
:return: Пользователи, если существует, иначе None. Или собственный аккаунт, если совпадает ID.
|
516
|
+
"""
|
517
|
+
return await self._request_users_by_ids(user_ids)
|
518
|
+
|
519
|
+
async def update_account_info(self):
|
520
|
+
if not self.account.username:
|
521
|
+
await self._update_account_username()
|
522
|
+
|
523
|
+
await self.request_user_by_username(self.account.username)
|
386
524
|
|
387
525
|
async def upload_image(
|
388
526
|
self,
|
389
527
|
image: bytes,
|
390
528
|
attempts: int = 3,
|
391
529
|
timeout: float | tuple[float, float] = 10,
|
392
|
-
) ->
|
530
|
+
) -> Media:
|
393
531
|
"""
|
394
532
|
Upload image as bytes.
|
395
533
|
|
396
534
|
Иногда при первой попытке загрузки изображения возвращает 408,
|
397
535
|
после чего повторная попытка загрузки изображения проходит успешно
|
398
536
|
|
399
|
-
:return: Media
|
537
|
+
:return: Media
|
400
538
|
"""
|
401
539
|
url = "https://upload.twitter.com/1.1/media/upload.json"
|
402
|
-
|
403
|
-
data = {"media_data": base64.b64encode(image)}
|
404
|
-
|
540
|
+
payload = {"media_data": base64.b64encode(image)}
|
405
541
|
for attempt in range(attempts):
|
406
542
|
try:
|
407
|
-
response,
|
408
|
-
"POST", url, data=
|
543
|
+
response, data = await self.request(
|
544
|
+
"POST", url, data=payload, timeout=timeout
|
409
545
|
)
|
410
|
-
|
411
|
-
return media_id
|
546
|
+
return Media(**data)
|
412
547
|
except (HTTPException, requests.errors.RequestsError) as exc:
|
413
548
|
if (
|
414
549
|
attempt < attempts - 1
|
@@ -425,9 +560,6 @@ class Client(BaseClient):
|
|
425
560
|
else:
|
426
561
|
raise
|
427
562
|
|
428
|
-
media_id = response_json["media_id"]
|
429
|
-
return media_id
|
430
|
-
|
431
563
|
async def _follow_action(self, action: str, user_id: int | str) -> bool:
|
432
564
|
url = f"https://twitter.com/i/api/1.1/friendships/{action}.json"
|
433
565
|
params = {
|
@@ -466,27 +598,76 @@ class Client(BaseClient):
|
|
466
598
|
"variables": {"tweet_id": tweet_id, "dark_request": False},
|
467
599
|
"queryId": query_id,
|
468
600
|
}
|
469
|
-
response,
|
470
|
-
return
|
601
|
+
response, data = await self.request("POST", url, json=json_payload)
|
602
|
+
return data
|
603
|
+
|
604
|
+
async def _repost(self, tweet_id: int | str) -> Tweet:
|
605
|
+
data = await self._interact_with_tweet("CreateRetweet", tweet_id)
|
606
|
+
tweet_id = data["data"]["create_retweet"]["retweet_results"]["result"]["rest_id"] # type: ignore
|
607
|
+
return await self.request_tweet(tweet_id)
|
608
|
+
|
609
|
+
async def _repost_or_search_duplicate(
|
610
|
+
self,
|
611
|
+
tweet_id: int,
|
612
|
+
*,
|
613
|
+
search_duplicate: bool = True,
|
614
|
+
) -> Tweet:
|
615
|
+
try:
|
616
|
+
tweet = await self._repost(tweet_id)
|
617
|
+
except HTTPException as exc:
|
618
|
+
if (
|
619
|
+
search_duplicate
|
620
|
+
and 327
|
621
|
+
in exc.api_codes # duplicate retweet (You have already retweeted this Tweet)
|
622
|
+
):
|
623
|
+
tweets = await self.request_tweets(self.account.id)
|
624
|
+
duplicate_tweet = None
|
625
|
+
for tweet_ in tweets: # type: Tweet
|
626
|
+
if tweet_.retweeted_tweet and tweet_.retweeted_tweet.id == tweet_id:
|
627
|
+
duplicate_tweet = tweet_
|
628
|
+
|
629
|
+
if not duplicate_tweet:
|
630
|
+
raise FailedToFindDuplicatePost(
|
631
|
+
f"Couldn't find a post duplicate in the next 20 posts"
|
632
|
+
)
|
633
|
+
|
634
|
+
tweet = duplicate_tweet
|
471
635
|
|
472
|
-
|
636
|
+
else:
|
637
|
+
raise
|
638
|
+
|
639
|
+
return tweet
|
640
|
+
|
641
|
+
async def repost(
|
642
|
+
self,
|
643
|
+
tweet_id: int,
|
644
|
+
*,
|
645
|
+
search_duplicate: bool = True,
|
646
|
+
) -> Tweet:
|
473
647
|
"""
|
474
648
|
Repost (retweet)
|
475
649
|
|
476
|
-
|
650
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
651
|
+
|
652
|
+
:return: Tweet
|
477
653
|
"""
|
478
|
-
|
479
|
-
|
480
|
-
response_json["data"]["create_retweet"]["retweet_results"]["result"][
|
481
|
-
"rest_id"
|
482
|
-
]
|
654
|
+
return await self._repost_or_search_duplicate(
|
655
|
+
tweet_id, search_duplicate=search_duplicate
|
483
656
|
)
|
484
|
-
return retweet_id
|
485
657
|
|
486
658
|
async def like(self, tweet_id: int) -> bool:
|
487
|
-
|
488
|
-
|
489
|
-
|
659
|
+
"""
|
660
|
+
:return: Liked or not
|
661
|
+
"""
|
662
|
+
try:
|
663
|
+
response_json = await self._interact_with_tweet("FavoriteTweet", tweet_id)
|
664
|
+
except HTTPException as exc:
|
665
|
+
if 139 in exc.api_codes:
|
666
|
+
# Already liked
|
667
|
+
return True
|
668
|
+
else:
|
669
|
+
raise
|
670
|
+
return response_json["data"]["favorite_tweet"] == "Done"
|
490
671
|
|
491
672
|
async def unlike(self, tweet_id: int) -> dict:
|
492
673
|
response_json = await self._interact_with_tweet("UnfavoriteTweet", tweet_id)
|
@@ -533,47 +714,50 @@ class Client(BaseClient):
|
|
533
714
|
attachment_url: str = None,
|
534
715
|
) -> Tweet:
|
535
716
|
url, query_id = self._action_to_url("CreateTweet")
|
536
|
-
|
537
|
-
"
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
"semantic_annotation_ids": [],
|
542
|
-
},
|
543
|
-
"features": {
|
544
|
-
"tweetypie_unmention_optimization_enabled": True,
|
545
|
-
"responsive_web_edit_tweet_api_enabled": True,
|
546
|
-
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
547
|
-
"view_counts_everywhere_api_enabled": True,
|
548
|
-
"longform_notetweets_consumption_enabled": True,
|
549
|
-
"tweet_awards_web_tipping_enabled": False,
|
550
|
-
"longform_notetweets_rich_text_read_enabled": True,
|
551
|
-
"longform_notetweets_inline_media_enabled": True,
|
552
|
-
"responsive_web_graphql_exclude_directive_enabled": True,
|
553
|
-
"verified_phone_label_enabled": False,
|
554
|
-
"freedom_of_speech_not_reach_fetch_enabled": True,
|
555
|
-
"standardized_nudges_misinfo": True,
|
556
|
-
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": False,
|
557
|
-
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
558
|
-
"responsive_web_graphql_timeline_navigation_enabled": True,
|
559
|
-
"responsive_web_enhance_cards_enabled": False,
|
560
|
-
"responsive_web_twitter_article_tweet_consumption_enabled": False,
|
561
|
-
"responsive_web_media_download_video_enabled": False,
|
562
|
-
},
|
563
|
-
"queryId": query_id,
|
717
|
+
variables = {
|
718
|
+
"tweet_text": text if text is not None else "",
|
719
|
+
"dark_request": False,
|
720
|
+
"media": {"media_entities": [], "possibly_sensitive": False},
|
721
|
+
"semantic_annotation_ids": [],
|
564
722
|
}
|
565
723
|
if attachment_url:
|
566
|
-
|
724
|
+
variables["attachment_url"] = attachment_url
|
567
725
|
if tweet_id_to_reply:
|
568
|
-
|
726
|
+
variables["reply"] = {
|
569
727
|
"in_reply_to_tweet_id": str(tweet_id_to_reply),
|
570
728
|
"exclude_reply_user_ids": [],
|
571
729
|
}
|
572
730
|
if media_id:
|
573
|
-
|
731
|
+
variables["media"]["media_entities"].append(
|
574
732
|
{"media_id": str(media_id), "tagged_users": []}
|
575
733
|
)
|
576
|
-
|
734
|
+
features = {
|
735
|
+
"communities_web_enable_tweet_community_results_fetch": True,
|
736
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
737
|
+
"tweetypie_unmention_optimization_enabled": True,
|
738
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
739
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
740
|
+
"view_counts_everywhere_api_enabled": True,
|
741
|
+
"longform_notetweets_consumption_enabled": True,
|
742
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": True,
|
743
|
+
"tweet_awards_web_tipping_enabled": False,
|
744
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
745
|
+
"longform_notetweets_inline_media_enabled": True,
|
746
|
+
"rweb_video_timestamps_enabled": True,
|
747
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
748
|
+
"verified_phone_label_enabled": False,
|
749
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
750
|
+
"standardized_nudges_misinfo": True,
|
751
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
752
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
753
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
754
|
+
"responsive_web_enhance_cards_enabled": False,
|
755
|
+
}
|
756
|
+
payload = {
|
757
|
+
"variables": variables,
|
758
|
+
"features": features,
|
759
|
+
"queryId": query_id,
|
760
|
+
}
|
577
761
|
response, response_json = await self.request("POST", url, json=payload)
|
578
762
|
tweet = Tweet.from_raw_data(
|
579
763
|
response_json["data"]["create_tweet"]["tweet_results"]["result"]
|
@@ -588,7 +772,6 @@ class Client(BaseClient):
|
|
588
772
|
tweet_id_to_reply: str | int = None,
|
589
773
|
attachment_url: str = None,
|
590
774
|
search_duplicate: bool = True,
|
591
|
-
with_tweet_url: bool = True,
|
592
775
|
) -> Tweet:
|
593
776
|
try:
|
594
777
|
tweet = await self._tweet(
|
@@ -602,10 +785,10 @@ class Client(BaseClient):
|
|
602
785
|
search_duplicate
|
603
786
|
and 187 in exc.api_codes # duplicate tweet (Status is a duplicate)
|
604
787
|
):
|
605
|
-
tweets = await self.request_tweets(
|
788
|
+
tweets = await self.request_tweets()
|
606
789
|
duplicate_tweet = None
|
607
790
|
for tweet_ in tweets:
|
608
|
-
if tweet_.
|
791
|
+
if tweet_.text.startswith(text.strip()):
|
609
792
|
duplicate_tweet = tweet_
|
610
793
|
|
611
794
|
if not duplicate_tweet:
|
@@ -617,11 +800,6 @@ class Client(BaseClient):
|
|
617
800
|
else:
|
618
801
|
raise
|
619
802
|
|
620
|
-
if with_tweet_url:
|
621
|
-
if not self.account.username:
|
622
|
-
await self.request_user_data()
|
623
|
-
tweet.url = create_tweet_url(self.account.username, tweet.id)
|
624
|
-
|
625
803
|
return tweet
|
626
804
|
|
627
805
|
async def tweet(
|
@@ -630,13 +808,16 @@ class Client(BaseClient):
|
|
630
808
|
*,
|
631
809
|
media_id: int | str = None,
|
632
810
|
search_duplicate: bool = True,
|
633
|
-
with_tweet_url: bool = True,
|
634
811
|
) -> Tweet:
|
812
|
+
"""
|
813
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
814
|
+
|
815
|
+
:return: Tweet
|
816
|
+
"""
|
635
817
|
return await self._tweet_or_search_duplicate(
|
636
818
|
text,
|
637
819
|
media_id=media_id,
|
638
820
|
search_duplicate=search_duplicate,
|
639
|
-
with_tweet_url=with_tweet_url,
|
640
821
|
)
|
641
822
|
|
642
823
|
async def reply(
|
@@ -646,14 +827,17 @@ class Client(BaseClient):
|
|
646
827
|
*,
|
647
828
|
media_id: int | str = None,
|
648
829
|
search_duplicate: bool = True,
|
649
|
-
with_tweet_url: bool = True,
|
650
830
|
) -> Tweet:
|
831
|
+
"""
|
832
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
833
|
+
|
834
|
+
:return: Tweet
|
835
|
+
"""
|
651
836
|
return await self._tweet_or_search_duplicate(
|
652
837
|
text,
|
653
838
|
media_id=media_id,
|
654
839
|
tweet_id_to_reply=tweet_id,
|
655
840
|
search_duplicate=search_duplicate,
|
656
|
-
with_tweet_url=with_tweet_url,
|
657
841
|
)
|
658
842
|
|
659
843
|
async def quote(
|
@@ -663,14 +847,17 @@ class Client(BaseClient):
|
|
663
847
|
*,
|
664
848
|
media_id: int | str = None,
|
665
849
|
search_duplicate: bool = True,
|
666
|
-
with_tweet_url: bool = True,
|
667
850
|
) -> Tweet:
|
851
|
+
"""
|
852
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
853
|
+
|
854
|
+
:return: Tweet
|
855
|
+
"""
|
668
856
|
return await self._tweet_or_search_duplicate(
|
669
857
|
text,
|
670
858
|
media_id=media_id,
|
671
859
|
attachment_url=tweet_url,
|
672
860
|
search_duplicate=search_duplicate,
|
673
|
-
with_tweet_url=with_tweet_url,
|
674
861
|
)
|
675
862
|
|
676
863
|
async def vote(
|
@@ -690,15 +877,21 @@ class Client(BaseClient):
|
|
690
877
|
response, response_json = await self.request("POST", url, params=params)
|
691
878
|
return response_json
|
692
879
|
|
693
|
-
async def
|
694
|
-
self,
|
695
|
-
|
880
|
+
async def _request_users_by_action(
|
881
|
+
self,
|
882
|
+
action: str,
|
883
|
+
user_id: int | str,
|
884
|
+
count: int,
|
885
|
+
cursor: str = None,
|
886
|
+
) -> list[User]:
|
696
887
|
url, query_id = self._action_to_url(action)
|
697
888
|
variables = {
|
698
889
|
"userId": str(user_id),
|
699
890
|
"count": count,
|
700
891
|
"includePromotedContent": False,
|
701
892
|
}
|
893
|
+
if cursor:
|
894
|
+
variables["cursor"] = cursor
|
702
895
|
features = {
|
703
896
|
"rweb_lists_timeline_redesign_enabled": True,
|
704
897
|
"responsive_web_graphql_exclude_directive_enabled": True,
|
@@ -737,40 +930,53 @@ class Client(BaseClient):
|
|
737
930
|
user_data_dict = entry["content"]["itemContent"]["user_results"][
|
738
931
|
"result"
|
739
932
|
]
|
740
|
-
users.append(
|
933
|
+
users.append(User.from_raw_data(user_data_dict))
|
741
934
|
return users
|
742
935
|
|
743
936
|
async def request_followers(
|
744
|
-
self,
|
745
|
-
|
937
|
+
self,
|
938
|
+
user_id: int | str = None,
|
939
|
+
count: int = 20,
|
940
|
+
cursor: str = None,
|
941
|
+
) -> list[User]:
|
746
942
|
"""
|
747
943
|
:param user_id: Текущий пользователь, если не передан ID иного пользователя.
|
748
944
|
:param count: Количество подписчиков.
|
749
945
|
"""
|
750
946
|
if user_id:
|
751
|
-
return await self.
|
947
|
+
return await self._request_users_by_action(
|
948
|
+
"Followers", user_id, count, cursor
|
949
|
+
)
|
752
950
|
else:
|
753
951
|
if not self.account.id:
|
754
|
-
await self.
|
755
|
-
return await self.
|
952
|
+
await self.update_account_info()
|
953
|
+
return await self._request_users_by_action(
|
954
|
+
"Followers", self.account.id, count, cursor
|
955
|
+
)
|
756
956
|
|
757
957
|
async def request_followings(
|
758
|
-
self,
|
759
|
-
|
958
|
+
self,
|
959
|
+
user_id: int | str = None,
|
960
|
+
count: int = 20,
|
961
|
+
cursor: str = None,
|
962
|
+
) -> list[User]:
|
760
963
|
"""
|
761
964
|
:param user_id: Текущий пользователь, если не передан ID иного пользователя.
|
762
965
|
:param count: Количество подписчиков.
|
763
966
|
"""
|
764
967
|
if user_id:
|
765
|
-
return await self.
|
968
|
+
return await self._request_users_by_action(
|
969
|
+
"Following", user_id, count, cursor
|
970
|
+
)
|
766
971
|
else:
|
767
972
|
if not self.account.id:
|
768
|
-
await self.
|
769
|
-
return await self.
|
973
|
+
await self.update_account_info()
|
974
|
+
return await self._request_users_by_action(
|
975
|
+
"Following", self.account.id, count, cursor
|
976
|
+
)
|
770
977
|
|
771
|
-
async def
|
772
|
-
|
773
|
-
url, query_id = self._action_to_url(action)
|
978
|
+
async def _request_tweet(self, tweet_id: int | str) -> Tweet:
|
979
|
+
url, query_id = self._action_to_url("TweetDetail")
|
774
980
|
variables = {
|
775
981
|
"focalTweetId": str(tweet_id),
|
776
982
|
"with_rux_injections": False,
|
@@ -801,12 +1007,73 @@ class Client(BaseClient):
|
|
801
1007
|
"longform_notetweets_inline_media_enabled": True,
|
802
1008
|
"responsive_web_enhance_cards_enabled": False,
|
803
1009
|
}
|
804
|
-
|
1010
|
+
query = {
|
805
1011
|
"variables": to_json(variables),
|
806
1012
|
"features": to_json(features),
|
807
1013
|
}
|
808
|
-
response,
|
809
|
-
|
1014
|
+
response, data = await self.request("GET", url, params=query)
|
1015
|
+
instructions = data["data"]["threaded_conversation_with_injections_v2"]["instructions"] # type: ignore
|
1016
|
+
tweet_data = tweets_data_from_instructions(instructions)[0]
|
1017
|
+
return Tweet.from_raw_data(tweet_data)
|
1018
|
+
|
1019
|
+
async def _request_tweets(
|
1020
|
+
self, user_id: int | str, count: int = 20, cursor: str = None
|
1021
|
+
) -> list[Tweet]:
|
1022
|
+
url, query_id = self._action_to_url("UserTweets")
|
1023
|
+
variables = {
|
1024
|
+
"userId": str(user_id),
|
1025
|
+
"count": count,
|
1026
|
+
"includePromotedContent": True,
|
1027
|
+
"withQuickPromoteEligibilityTweetFields": True,
|
1028
|
+
"withVoice": True,
|
1029
|
+
"withV2Timeline": True,
|
1030
|
+
}
|
1031
|
+
if cursor:
|
1032
|
+
variables["cursor"] = cursor
|
1033
|
+
features = {
|
1034
|
+
"responsive_web_graphql_exclude_directive_enabled": True,
|
1035
|
+
"verified_phone_label_enabled": False,
|
1036
|
+
"creator_subscriptions_tweet_preview_api_enabled": True,
|
1037
|
+
"responsive_web_graphql_timeline_navigation_enabled": True,
|
1038
|
+
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
1039
|
+
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
1040
|
+
"tweetypie_unmention_optimization_enabled": True,
|
1041
|
+
"responsive_web_edit_tweet_api_enabled": True,
|
1042
|
+
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
1043
|
+
"view_counts_everywhere_api_enabled": True,
|
1044
|
+
"longform_notetweets_consumption_enabled": True,
|
1045
|
+
"responsive_web_twitter_article_tweet_consumption_enabled": False,
|
1046
|
+
"tweet_awards_web_tipping_enabled": False,
|
1047
|
+
"freedom_of_speech_not_reach_fetch_enabled": True,
|
1048
|
+
"standardized_nudges_misinfo": True,
|
1049
|
+
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
1050
|
+
"rweb_video_timestamps_enabled": True,
|
1051
|
+
"longform_notetweets_rich_text_read_enabled": True,
|
1052
|
+
"longform_notetweets_inline_media_enabled": True,
|
1053
|
+
"responsive_web_media_download_video_enabled": False,
|
1054
|
+
"responsive_web_enhance_cards_enabled": False,
|
1055
|
+
}
|
1056
|
+
params = {"variables": to_json(variables), "features": to_json(features)}
|
1057
|
+
response, data = await self.request("GET", url, params=params)
|
1058
|
+
|
1059
|
+
instructions = data["data"]["user"]["result"]["timeline_v2"]["timeline"][
|
1060
|
+
"instructions"
|
1061
|
+
]
|
1062
|
+
tweets_data = tweets_data_from_instructions(instructions)
|
1063
|
+
return [Tweet.from_raw_data(tweet_data) for tweet_data in tweets_data]
|
1064
|
+
|
1065
|
+
async def request_tweet(self, tweet_id: int | str) -> Tweet:
|
1066
|
+
return await self._request_tweet(tweet_id)
|
1067
|
+
|
1068
|
+
async def request_tweets(
|
1069
|
+
self, user_id: int | str = None, count: int = 20, cursor: str = None
|
1070
|
+
) -> list[Tweet]:
|
1071
|
+
if not user_id:
|
1072
|
+
if not self.account.id:
|
1073
|
+
await self.update_account_info()
|
1074
|
+
user_id = self.account.id
|
1075
|
+
|
1076
|
+
return await self._request_tweets(user_id, count, cursor)
|
810
1077
|
|
811
1078
|
async def _update_profile_image(
|
812
1079
|
self, type: Literal["banner", "image"], media_id: str | int
|
@@ -832,8 +1099,8 @@ class Client(BaseClient):
|
|
832
1099
|
"skip_status": "1",
|
833
1100
|
"return_user": "true",
|
834
1101
|
}
|
835
|
-
response,
|
836
|
-
image_url =
|
1102
|
+
response, data = await self.request("POST", url, params=params)
|
1103
|
+
image_url = data[f"profile_{type}_url"]
|
837
1104
|
return image_url
|
838
1105
|
|
839
1106
|
async def update_profile_avatar(self, media_id: int | str) -> str:
|
@@ -850,12 +1117,12 @@ class Client(BaseClient):
|
|
850
1117
|
|
851
1118
|
async def change_username(self, username: str) -> bool:
|
852
1119
|
url = "https://twitter.com/i/api/1.1/account/settings.json"
|
853
|
-
|
854
|
-
response,
|
855
|
-
new_username =
|
856
|
-
|
1120
|
+
payload = {"screen_name": username}
|
1121
|
+
response, data = await self.request("POST", url, data=payload)
|
1122
|
+
new_username = data["screen_name"]
|
1123
|
+
changed = new_username == username
|
857
1124
|
self.account.username = new_username
|
858
|
-
return
|
1125
|
+
return changed
|
859
1126
|
|
860
1127
|
async def change_password(self, password: str) -> bool:
|
861
1128
|
"""
|
@@ -865,17 +1132,15 @@ class Client(BaseClient):
|
|
865
1132
|
raise ValueError(f"Specify the current password before changing it")
|
866
1133
|
|
867
1134
|
url = "https://twitter.com/i/api/i/account/change_password.json"
|
868
|
-
|
1135
|
+
payload = {
|
869
1136
|
"current_password": self.account.password,
|
870
1137
|
"password": password,
|
871
1138
|
"password_confirmation": password,
|
872
1139
|
}
|
873
|
-
response,
|
874
|
-
|
875
|
-
auth_token = response.cookies.get("auth_token", domain=".twitter.com")
|
876
|
-
self.account.auth_token = auth_token
|
1140
|
+
response, data = await self.request("POST", url, data=payload)
|
1141
|
+
changed = data["status"] == "ok"
|
877
1142
|
self.account.password = password
|
878
|
-
return
|
1143
|
+
return changed
|
879
1144
|
|
880
1145
|
async def update_profile(
|
881
1146
|
self,
|
@@ -891,9 +1156,8 @@ class Client(BaseClient):
|
|
891
1156
|
raise ValueError("Specify at least one param")
|
892
1157
|
|
893
1158
|
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
894
|
-
headers = {"content-type": "application/x-www-form-urlencoded"}
|
895
1159
|
# Создаем словарь data, включая в него только те ключи, для которых значения не равны None
|
896
|
-
|
1160
|
+
payload = {
|
897
1161
|
k: v
|
898
1162
|
for k, v in [
|
899
1163
|
("name", name),
|
@@ -903,26 +1167,23 @@ class Client(BaseClient):
|
|
903
1167
|
]
|
904
1168
|
if v is not None
|
905
1169
|
}
|
906
|
-
response,
|
907
|
-
"POST", url, headers=headers, data=data
|
908
|
-
)
|
1170
|
+
response, data = await self.request("POST", url, data=payload)
|
909
1171
|
# Проверяем, что все переданные параметры соответствуют полученным
|
910
|
-
|
911
|
-
|
912
|
-
for key, value in data.items()
|
913
|
-
if key != "url"
|
1172
|
+
updated = all(
|
1173
|
+
data.get(key) == value for key, value in payload.items() if key != "url"
|
914
1174
|
)
|
915
1175
|
if website:
|
916
|
-
|
917
|
-
|
1176
|
+
updated &= URL(website) == URL(
|
1177
|
+
data["entities"]["url"]["urls"][0]["expanded_url"]
|
918
1178
|
)
|
919
|
-
await self.
|
920
|
-
return
|
1179
|
+
await self.update_account_info()
|
1180
|
+
return updated
|
921
1181
|
|
922
1182
|
async def establish_status(self):
|
923
1183
|
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
924
1184
|
try:
|
925
|
-
await self.request("POST", url)
|
1185
|
+
await self.request("POST", url, auto_unlock=False, auto_relogin=False)
|
1186
|
+
self.account.status = AccountStatus.GOOD
|
926
1187
|
except BadAccount:
|
927
1188
|
pass
|
928
1189
|
|
@@ -935,19 +1196,16 @@ class Client(BaseClient):
|
|
935
1196
|
year_visibility: Literal["self"] = "self",
|
936
1197
|
) -> bool:
|
937
1198
|
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
938
|
-
|
939
|
-
data = {
|
1199
|
+
payload = {
|
940
1200
|
"birthdate_day": day,
|
941
1201
|
"birthdate_month": month,
|
942
1202
|
"birthdate_year": year,
|
943
1203
|
"birthdate_visibility": visibility,
|
944
1204
|
"birthdate_year_visibility": year_visibility,
|
945
1205
|
}
|
946
|
-
response, response_json = await self.request(
|
947
|
-
"POST", url, headers=headers, data=data
|
948
|
-
)
|
1206
|
+
response, response_json = await self.request("POST", url, json=payload)
|
949
1207
|
birthdate_data = response_json["extended_profile"]["birthdate"]
|
950
|
-
|
1208
|
+
updated = all(
|
951
1209
|
(
|
952
1210
|
birthdate_data["day"] == day,
|
953
1211
|
birthdate_data["month"] == month,
|
@@ -956,7 +1214,7 @@ class Client(BaseClient):
|
|
956
1214
|
birthdate_data["year_visibility"] == year_visibility,
|
957
1215
|
)
|
958
1216
|
)
|
959
|
-
return
|
1217
|
+
return updated
|
960
1218
|
|
961
1219
|
async def send_message(self, user_id: int | str, text: str) -> dict:
|
962
1220
|
"""
|
@@ -972,6 +1230,20 @@ class Client(BaseClient):
|
|
972
1230
|
},
|
973
1231
|
}
|
974
1232
|
}
|
1233
|
+
response, data = await self.request("POST", url, json=payload)
|
1234
|
+
event_data = data["event"]
|
1235
|
+
return event_data # TODO Возвращать модель, а не словарь
|
1236
|
+
|
1237
|
+
async def send_message_to_conversation(
|
1238
|
+
self, conversation_id: int | str, text: str
|
1239
|
+
) -> dict:
|
1240
|
+
"""
|
1241
|
+
requires OAuth1 or OAuth2
|
1242
|
+
|
1243
|
+
:return: Event data
|
1244
|
+
"""
|
1245
|
+
url = f"https://api.twitter.com/2/dm_conversations/{conversation_id}/messages"
|
1246
|
+
payload = {"text": text}
|
975
1247
|
response, response_json = await self.request("POST", url, json=payload)
|
976
1248
|
event_data = response_json["event"]
|
977
1249
|
return event_data
|
@@ -1023,56 +1295,7 @@ class Client(BaseClient):
|
|
1023
1295
|
for entry in response_json["inbox_initial_state"]["entries"]
|
1024
1296
|
if "message" in entry
|
1025
1297
|
]
|
1026
|
-
return messages
|
1027
|
-
|
1028
|
-
async def request_tweets(self, user_id: str | int, count: int = 20) -> list[Tweet]:
|
1029
|
-
url, query_id = self._action_to_url("UserTweets")
|
1030
|
-
variables = {
|
1031
|
-
"userId": str(user_id),
|
1032
|
-
"count": count,
|
1033
|
-
"includePromotedContent": True,
|
1034
|
-
"withQuickPromoteEligibilityTweetFields": True,
|
1035
|
-
"withVoice": True,
|
1036
|
-
"withV2Timeline": True,
|
1037
|
-
}
|
1038
|
-
features = {
|
1039
|
-
"responsive_web_graphql_exclude_directive_enabled": True,
|
1040
|
-
"verified_phone_label_enabled": False,
|
1041
|
-
"creator_subscriptions_tweet_preview_api_enabled": True,
|
1042
|
-
"responsive_web_graphql_timeline_navigation_enabled": True,
|
1043
|
-
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
1044
|
-
"c9s_tweet_anatomy_moderator_badge_enabled": True,
|
1045
|
-
"tweetypie_unmention_optimization_enabled": True,
|
1046
|
-
"responsive_web_edit_tweet_api_enabled": True,
|
1047
|
-
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
1048
|
-
"view_counts_everywhere_api_enabled": True,
|
1049
|
-
"longform_notetweets_consumption_enabled": True,
|
1050
|
-
"responsive_web_twitter_article_tweet_consumption_enabled": False,
|
1051
|
-
"tweet_awards_web_tipping_enabled": False,
|
1052
|
-
"freedom_of_speech_not_reach_fetch_enabled": True,
|
1053
|
-
"standardized_nudges_misinfo": True,
|
1054
|
-
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": True,
|
1055
|
-
"rweb_video_timestamps_enabled": True,
|
1056
|
-
"longform_notetweets_rich_text_read_enabled": True,
|
1057
|
-
"longform_notetweets_inline_media_enabled": True,
|
1058
|
-
"responsive_web_media_download_video_enabled": False,
|
1059
|
-
"responsive_web_enhance_cards_enabled": False,
|
1060
|
-
}
|
1061
|
-
params = {"variables": to_json(variables), "features": to_json(features)}
|
1062
|
-
response, response_json = await self.request("GET", url, params=params)
|
1063
|
-
|
1064
|
-
tweets = []
|
1065
|
-
for instruction in response_json["data"]["user"]["result"]["timeline_v2"][
|
1066
|
-
"timeline"
|
1067
|
-
]["instructions"]:
|
1068
|
-
if instruction["type"] == "TimelineAddEntries":
|
1069
|
-
for entry in instruction["entries"]:
|
1070
|
-
if entry["entryId"].startswith("tweet"):
|
1071
|
-
tweet_data = entry["content"]["itemContent"]["tweet_results"][
|
1072
|
-
"result"
|
1073
|
-
]
|
1074
|
-
tweets.append(Tweet.from_raw_data(tweet_data))
|
1075
|
-
return tweets
|
1298
|
+
return messages # TODO Возвращать модели, а не словари
|
1076
1299
|
|
1077
1300
|
async def _confirm_unlock(
|
1078
1301
|
self,
|
@@ -1090,6 +1313,8 @@ class Client(BaseClient):
|
|
1090
1313
|
payload["verification_string"] = verification_string
|
1091
1314
|
payload["language_code"] = "en"
|
1092
1315
|
|
1316
|
+
# TODO ui_metrics
|
1317
|
+
|
1093
1318
|
return await self.request("POST", self._CAPTCHA_URL, data=payload, bearer=False)
|
1094
1319
|
|
1095
1320
|
async def unlock(self):
|
@@ -1103,9 +1328,23 @@ class Client(BaseClient):
|
|
1103
1328
|
needs_unlock,
|
1104
1329
|
start_button,
|
1105
1330
|
finish_button,
|
1331
|
+
delete_button,
|
1106
1332
|
) = parse_unlock_html(html)
|
1107
1333
|
attempt = 1
|
1108
1334
|
|
1335
|
+
if delete_button:
|
1336
|
+
response, html = await self._confirm_unlock(
|
1337
|
+
authenticity_token, assignment_token
|
1338
|
+
)
|
1339
|
+
(
|
1340
|
+
authenticity_token,
|
1341
|
+
assignment_token,
|
1342
|
+
needs_unlock,
|
1343
|
+
start_button,
|
1344
|
+
finish_button,
|
1345
|
+
delete_button,
|
1346
|
+
) = parse_unlock_html(html)
|
1347
|
+
|
1109
1348
|
if start_button or finish_button:
|
1110
1349
|
response, html = await self._confirm_unlock(
|
1111
1350
|
authenticity_token, assignment_token
|
@@ -1116,6 +1355,7 @@ class Client(BaseClient):
|
|
1116
1355
|
needs_unlock,
|
1117
1356
|
start_button,
|
1118
1357
|
finish_button,
|
1358
|
+
delete_button,
|
1119
1359
|
) = parse_unlock_html(html)
|
1120
1360
|
|
1121
1361
|
funcaptcha = {
|
@@ -1133,8 +1373,20 @@ class Client(BaseClient):
|
|
1133
1373
|
else:
|
1134
1374
|
funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
|
1135
1375
|
|
1136
|
-
while needs_unlock:
|
1376
|
+
while needs_unlock and attempt <= self.max_unlock_attempts:
|
1137
1377
|
solution = await FunCaptcha(**funcaptcha).aio_captcha_handler()
|
1378
|
+
if solution.errorId:
|
1379
|
+
logger.warning(
|
1380
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
1381
|
+
f"Failed to solve funcaptcha:"
|
1382
|
+
f"\n\tUnlock attempt: {attempt}/{self.max_unlock_attempts}"
|
1383
|
+
f"\n\tError ID: {solution.errorId}"
|
1384
|
+
f"\n\tError code: {solution.errorCode}"
|
1385
|
+
f"\n\tError description: {solution.errorDescription}"
|
1386
|
+
)
|
1387
|
+
attempt += 1
|
1388
|
+
continue
|
1389
|
+
|
1138
1390
|
token = solution.solution["token"]
|
1139
1391
|
response, html = await self._confirm_unlock(
|
1140
1392
|
authenticity_token,
|
@@ -1142,12 +1394,8 @@ class Client(BaseClient):
|
|
1142
1394
|
verification_string=token,
|
1143
1395
|
)
|
1144
1396
|
|
1145
|
-
if
|
1146
|
-
|
1147
|
-
or response.url == "https://twitter.com/?lang=en"
|
1148
|
-
):
|
1149
|
-
await self.establish_status()
|
1150
|
-
return
|
1397
|
+
if response.url == "https://twitter.com/?lang=en":
|
1398
|
+
break
|
1151
1399
|
|
1152
1400
|
(
|
1153
1401
|
authenticity_token,
|
@@ -1155,6 +1403,7 @@ class Client(BaseClient):
|
|
1155
1403
|
needs_unlock,
|
1156
1404
|
start_button,
|
1157
1405
|
finish_button,
|
1406
|
+
delete_button,
|
1158
1407
|
) = parse_unlock_html(html)
|
1159
1408
|
|
1160
1409
|
if finish_button:
|
@@ -1167,22 +1416,58 @@ class Client(BaseClient):
|
|
1167
1416
|
needs_unlock,
|
1168
1417
|
start_button,
|
1169
1418
|
finish_button,
|
1419
|
+
delete_button,
|
1170
1420
|
) = parse_unlock_html(html)
|
1171
1421
|
|
1172
1422
|
attempt += 1
|
1173
1423
|
|
1174
|
-
|
1424
|
+
await self.establish_status()
|
1425
|
+
|
1426
|
+
async def update_backup_code(self):
|
1427
|
+
url = "https://api.twitter.com/1.1/account/backup_code.json"
|
1428
|
+
response, response_json = await self.request("GET", url)
|
1429
|
+
self.account.backup_code = response_json["codes"][0]
|
1430
|
+
|
1431
|
+
async def _send_raw_subtask(self, **request_kwargs) -> tuple[str, list[Subtask]]:
|
1175
1432
|
"""
|
1176
|
-
:return: flow_token
|
1433
|
+
:return: flow_token and subtasks
|
1177
1434
|
"""
|
1178
1435
|
url = "https://api.twitter.com/1.1/onboarding/task.json"
|
1179
|
-
response,
|
1180
|
-
|
1436
|
+
response, data = await self.request("POST", url, **request_kwargs)
|
1437
|
+
subtasks = [
|
1438
|
+
Subtask.from_raw_data(subtask_data) for subtask_data in data["subtasks"]
|
1439
|
+
]
|
1440
|
+
log_message = (
|
1441
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
1442
|
+
f" Requested subtasks:"
|
1443
|
+
)
|
1444
|
+
for subtask in subtasks:
|
1445
|
+
log_message += f"\n\t{subtask.id}"
|
1446
|
+
if subtask.primary_text:
|
1447
|
+
log_message += f"\n\tPrimary text: {subtask.primary_text}"
|
1448
|
+
if subtask.secondary_text:
|
1449
|
+
log_message += f"\n\tSecondary text: {subtask.secondary_text}"
|
1450
|
+
if subtask.detail_text:
|
1451
|
+
log_message += f"\n\tDetail text: {subtask.detail_text}"
|
1452
|
+
logger.debug(log_message)
|
1453
|
+
return data["flow_token"], subtasks
|
1454
|
+
|
1455
|
+
async def _complete_subtask(
|
1456
|
+
self,
|
1457
|
+
flow_token: str,
|
1458
|
+
inputs: list[dict],
|
1459
|
+
**request_kwargs,
|
1460
|
+
) -> tuple[str, list[Subtask]]:
|
1461
|
+
payload = request_kwargs["json"] = request_kwargs.get("json") or {}
|
1462
|
+
payload.update(
|
1463
|
+
{
|
1464
|
+
"flow_token": flow_token,
|
1465
|
+
"subtask_inputs": inputs,
|
1466
|
+
}
|
1467
|
+
)
|
1468
|
+
return await self._send_raw_subtask(**request_kwargs)
|
1181
1469
|
|
1182
|
-
async def _request_login_tasks(self):
|
1183
|
-
"""
|
1184
|
-
:return: flow_token, subtask_ids
|
1185
|
-
"""
|
1470
|
+
async def _request_login_tasks(self) -> tuple[str, list[Subtask]]:
|
1186
1471
|
params = {
|
1187
1472
|
"flow_name": "login",
|
1188
1473
|
}
|
@@ -1237,84 +1522,89 @@ class Client(BaseClient):
|
|
1237
1522
|
"web_modal": 1,
|
1238
1523
|
},
|
1239
1524
|
}
|
1240
|
-
return await self.
|
1525
|
+
return await self._send_raw_subtask(params=params, json=payload, auth=False)
|
1241
1526
|
|
1242
|
-
async def
|
1243
|
-
|
1244
|
-
payload.update(
|
1245
|
-
{
|
1246
|
-
"flow_token": flow_token,
|
1247
|
-
"subtask_inputs": subtask_inputs,
|
1248
|
-
}
|
1249
|
-
)
|
1250
|
-
return await self._task(**kwargs)
|
1251
|
-
|
1252
|
-
async def _finish_task(self, flow_token):
|
1253
|
-
payload = {
|
1254
|
-
"flow_token": flow_token,
|
1255
|
-
"subtask_inputs": [],
|
1256
|
-
}
|
1257
|
-
return await self._task(json=payload)
|
1258
|
-
|
1259
|
-
async def _login_enter_user_identifier(self, flow_token):
|
1260
|
-
subtask_inputs = [
|
1527
|
+
async def _login_enter_user_identifier(self, flow_token: str):
|
1528
|
+
inputs = [
|
1261
1529
|
{
|
1262
1530
|
"subtask_id": "LoginEnterUserIdentifierSSO",
|
1263
1531
|
"settings_list": {
|
1532
|
+
"link": "next_link",
|
1264
1533
|
"setting_responses": [
|
1265
1534
|
{
|
1266
1535
|
"key": "user_identifier",
|
1267
1536
|
"response_data": {
|
1268
1537
|
"text_data": {
|
1269
|
-
"result": self.account.
|
1270
|
-
or self.account.
|
1538
|
+
"result": self.account.username
|
1539
|
+
or self.account.email
|
1271
1540
|
}
|
1272
1541
|
},
|
1273
1542
|
}
|
1274
1543
|
],
|
1275
|
-
"link": "next_link",
|
1276
1544
|
},
|
1277
1545
|
}
|
1278
1546
|
]
|
1279
|
-
return await self.
|
1547
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1280
1548
|
|
1281
|
-
async def _login_enter_password(self, flow_token):
|
1282
|
-
|
1549
|
+
async def _login_enter_password(self, flow_token: str):
|
1550
|
+
inputs = [
|
1283
1551
|
{
|
1284
1552
|
"subtask_id": "LoginEnterPassword",
|
1285
1553
|
"enter_password": {
|
1286
|
-
"password": self.account.password,
|
1287
1554
|
"link": "next_link",
|
1555
|
+
"password": self.account.password,
|
1288
1556
|
},
|
1289
1557
|
}
|
1290
1558
|
]
|
1291
|
-
return await self.
|
1559
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1292
1560
|
|
1293
1561
|
async def _account_duplication_check(self, flow_token):
|
1294
|
-
|
1562
|
+
inputs = [
|
1295
1563
|
{
|
1296
1564
|
"subtask_id": "AccountDuplicationCheck",
|
1297
1565
|
"check_logged_in_account": {"link": "AccountDuplicationCheck_false"},
|
1298
1566
|
}
|
1299
1567
|
]
|
1300
|
-
return await self.
|
1301
|
-
|
1302
|
-
async def _login_two_factor_auth_challenge(self, flow_token):
|
1303
|
-
if not self.account.totp_secret:
|
1304
|
-
raise TwitterException(
|
1305
|
-
f"Failed to login. Task id: LoginTwoFactorAuthChallenge"
|
1306
|
-
)
|
1568
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1307
1569
|
|
1308
|
-
|
1570
|
+
async def _login_two_factor_auth_challenge(self, flow_token, value: str):
|
1571
|
+
inputs = [
|
1309
1572
|
{
|
1310
1573
|
"subtask_id": "LoginTwoFactorAuthChallenge",
|
1311
1574
|
"enter_text": {
|
1312
|
-
"text": self.account.get_totp_code(),
|
1313
1575
|
"link": "next_link",
|
1576
|
+
"text": value,
|
1314
1577
|
},
|
1315
1578
|
}
|
1316
1579
|
]
|
1317
|
-
return await self.
|
1580
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1581
|
+
|
1582
|
+
async def _login_two_factor_auth_choose_method(
|
1583
|
+
self, flow_token: str, choices: Iterable[int] = (0,)
|
1584
|
+
):
|
1585
|
+
inputs = [
|
1586
|
+
{
|
1587
|
+
"subtask_id": "LoginTwoFactorAuthChooseMethod",
|
1588
|
+
"choice_selection": {
|
1589
|
+
"link": "next_link",
|
1590
|
+
"selected_choices": [str(choice) for choice in choices],
|
1591
|
+
},
|
1592
|
+
}
|
1593
|
+
]
|
1594
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1595
|
+
|
1596
|
+
async def _login_acid(
|
1597
|
+
self,
|
1598
|
+
flow_token: str,
|
1599
|
+
value: str,
|
1600
|
+
):
|
1601
|
+
inputs = [
|
1602
|
+
{
|
1603
|
+
"subtask_id": "LoginAcid",
|
1604
|
+
"enter_text": {"text": value, "link": "next_link"},
|
1605
|
+
}
|
1606
|
+
]
|
1607
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1318
1608
|
|
1319
1609
|
async def _viewer(self):
|
1320
1610
|
url, query_id = self._action_to_url("Viewer")
|
@@ -1331,11 +1621,11 @@ class Client(BaseClient):
|
|
1331
1621
|
}
|
1332
1622
|
variables = {"withCommunitiesMemberships": True}
|
1333
1623
|
params = {
|
1334
|
-
"features":
|
1335
|
-
"fieldToggles":
|
1336
|
-
"variables":
|
1624
|
+
"features": features,
|
1625
|
+
"fieldToggles": field_toggles,
|
1626
|
+
"variables": variables,
|
1337
1627
|
}
|
1338
|
-
return self.request("GET", url, params=params)
|
1628
|
+
return await self.request("GET", url, params=params)
|
1339
1629
|
|
1340
1630
|
async def _request_guest_token(self) -> str:
|
1341
1631
|
"""
|
@@ -1350,66 +1640,151 @@ class Client(BaseClient):
|
|
1350
1640
|
guest_token = re.search(r"gt\s?=\s?\d+", response.text)[0].split("=")[1]
|
1351
1641
|
return guest_token
|
1352
1642
|
|
1353
|
-
async def _login(self):
|
1643
|
+
async def _login(self) -> bool:
|
1644
|
+
update_backup_code = False
|
1645
|
+
|
1354
1646
|
guest_token = await self._request_guest_token()
|
1355
1647
|
self._session.headers["X-Guest-Token"] = guest_token
|
1356
1648
|
|
1357
|
-
# Можно не устанавливать, так как твиттер сам вернет этот токен
|
1358
|
-
# self._session.cookies["gt"] = guest_token
|
1359
|
-
|
1360
1649
|
flow_token, subtasks = await self._request_login_tasks()
|
1361
1650
|
for _ in range(2):
|
1362
1651
|
flow_token, subtasks = await self._login_enter_user_identifier(flow_token)
|
1652
|
+
|
1653
|
+
subtask_ids = {subtask.id for subtask in subtasks}
|
1654
|
+
if "LoginEnterAlternateIdentifierSubtask" in subtask_ids:
|
1655
|
+
if not self.account.username:
|
1656
|
+
raise TwitterException("Failed to login: no username to relogin")
|
1657
|
+
|
1363
1658
|
flow_token, subtasks = await self._login_enter_password(flow_token)
|
1364
1659
|
flow_token, subtasks = await self._account_duplication_check(flow_token)
|
1365
1660
|
|
1366
|
-
|
1661
|
+
for subtask in subtasks:
|
1662
|
+
if subtask.id == "LoginAcid":
|
1663
|
+
if not self.account.email:
|
1664
|
+
raise TwitterException(
|
1665
|
+
f"Failed to login. Task id: LoginAcid." f" No email!"
|
1666
|
+
)
|
1367
1667
|
|
1368
|
-
|
1369
|
-
|
1370
|
-
|
1668
|
+
if subtask.primary_text == "Check your email":
|
1669
|
+
raise TwitterException(
|
1670
|
+
f"Failed to login. Task id: LoginAcid."
|
1671
|
+
f" Email verification required!"
|
1672
|
+
f" No IMAP handler for this version of library :<"
|
1673
|
+
)
|
1371
1674
|
|
1372
|
-
|
1373
|
-
|
1374
|
-
|
1375
|
-
|
1675
|
+
try:
|
1676
|
+
# fmt: off
|
1677
|
+
flow_token, subtasks = await self._login_acid(flow_token, self.account.email)
|
1678
|
+
# fmt: on
|
1679
|
+
except HTTPException as exc:
|
1680
|
+
if 399 in exc.api_codes:
|
1681
|
+
logger.warning(
|
1682
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
1683
|
+
f" Bad email!"
|
1684
|
+
)
|
1685
|
+
raise TwitterException(
|
1686
|
+
f"Failed to login. Task id: LoginAcid. Bad email!"
|
1687
|
+
)
|
1688
|
+
else:
|
1689
|
+
raise
|
1690
|
+
|
1691
|
+
subtask_ids = {subtask.id for subtask in subtasks}
|
1376
1692
|
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
1693
|
+
if "LoginTwoFactorAuthChallenge" in subtask_ids:
|
1694
|
+
if not self.account.totp_secret:
|
1695
|
+
raise TwitterException(
|
1696
|
+
f"Failed to login. Task id: LoginTwoFactorAuthChallenge. No totp_secret!"
|
1697
|
+
)
|
1380
1698
|
|
1381
|
-
|
1699
|
+
try:
|
1700
|
+
# fmt: off
|
1701
|
+
flow_token, subtasks = await self._login_two_factor_auth_challenge(flow_token, self.account.get_totp_code())
|
1702
|
+
# fmt: on
|
1703
|
+
except HTTPException as exc:
|
1704
|
+
if 399 in exc.api_codes:
|
1705
|
+
logger.warning(
|
1706
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
1707
|
+
f" Bad TOTP secret!"
|
1708
|
+
)
|
1709
|
+
if not self.account.backup_code:
|
1710
|
+
raise TwitterException(
|
1711
|
+
f"Failed to login. Task id: LoginTwoFactorAuthChallenge. No backup code!"
|
1712
|
+
)
|
1713
|
+
|
1714
|
+
# Enter backup code
|
1715
|
+
# fmt: off
|
1716
|
+
flow_token, subtasks = await self._login_two_factor_auth_choose_method(flow_token)
|
1717
|
+
try:
|
1718
|
+
flow_token, subtasks = await self._login_two_factor_auth_challenge(flow_token, self.account.backup_code)
|
1719
|
+
except HTTPException as exc:
|
1720
|
+
if 399 in exc.api_codes:
|
1721
|
+
logger.warning(
|
1722
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
1723
|
+
f" Bad backup code!"
|
1724
|
+
)
|
1725
|
+
raise TwitterException(
|
1726
|
+
f"Failed to login. Task id: LoginTwoFactorAuthChallenge. Bad backup_code!"
|
1727
|
+
)
|
1728
|
+
else:
|
1729
|
+
raise
|
1730
|
+
|
1731
|
+
update_backup_code = True
|
1732
|
+
# fmt: on
|
1733
|
+
else:
|
1734
|
+
raise
|
1382
1735
|
|
1383
|
-
|
1384
|
-
|
1385
|
-
await self.establish_status()
|
1386
|
-
if self.account.status != "BAD_TOKEN":
|
1387
|
-
return
|
1736
|
+
await self._complete_subtask(flow_token, [])
|
1737
|
+
return update_backup_code
|
1388
1738
|
|
1739
|
+
async def relogin(self):
|
1740
|
+
"""
|
1741
|
+
Может вызвать следующую ошибку:
|
1742
|
+
twitter.errors.BadRequest: (response status: 400)
|
1743
|
+
(code 398) Can't complete your signup right now.
|
1744
|
+
Причина возникновения ошибки неизвестна. Не забудьте обработать ее.
|
1745
|
+
"""
|
1389
1746
|
if not self.account.email and not self.account.username:
|
1390
1747
|
raise ValueError("No email or username")
|
1391
1748
|
|
1392
1749
|
if not self.account.password:
|
1393
1750
|
raise ValueError("No password")
|
1394
1751
|
|
1395
|
-
await self._login()
|
1752
|
+
update_backup_code = await self._login()
|
1753
|
+
await self._viewer()
|
1754
|
+
|
1755
|
+
if update_backup_code:
|
1756
|
+
await self.update_backup_code()
|
1757
|
+
logger.warning(
|
1758
|
+
f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
1759
|
+
f" Requested new backup code!"
|
1760
|
+
)
|
1761
|
+
# TODO Также обновлять totp_secret
|
1762
|
+
|
1396
1763
|
await self.establish_status()
|
1397
1764
|
|
1765
|
+
async def login(self):
|
1766
|
+
if self.account.auth_token:
|
1767
|
+
await self.establish_status()
|
1768
|
+
if self.account.status not in ("BAD_TOKEN", "CONSENT_LOCKED"):
|
1769
|
+
return
|
1770
|
+
|
1771
|
+
await self.relogin()
|
1772
|
+
|
1398
1773
|
async def totp_is_enabled(self):
|
1399
1774
|
if not self.account.id:
|
1400
|
-
await self.
|
1775
|
+
await self.update_account_info()
|
1401
1776
|
|
1402
1777
|
url = f"https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2"
|
1403
1778
|
response, data = await self.request("GET", url)
|
1404
|
-
|
1405
|
-
|
1406
|
-
|
1779
|
+
# fmt: off
|
1780
|
+
return "Totp" in [method_data["twoFactorType"] for method_data in data["methods"]]
|
1781
|
+
# fmt: on
|
1407
1782
|
|
1408
|
-
async def _request_2fa_tasks(self):
|
1783
|
+
async def _request_2fa_tasks(self) -> tuple[str, list[Subtask]]:
|
1409
1784
|
"""
|
1410
|
-
:return: flow_token,
|
1785
|
+
:return: flow_token, tasks
|
1411
1786
|
"""
|
1412
|
-
|
1787
|
+
query = {
|
1413
1788
|
"flow_name": "two-factor-auth-app-enrollment",
|
1414
1789
|
}
|
1415
1790
|
payload = {
|
@@ -1463,34 +1838,37 @@ class Client(BaseClient):
|
|
1463
1838
|
"web_modal": 1,
|
1464
1839
|
},
|
1465
1840
|
}
|
1466
|
-
return await self.
|
1841
|
+
return await self._send_raw_subtask(params=query, json=payload)
|
1467
1842
|
|
1468
|
-
async def _two_factor_enrollment_verify_password_subtask(
|
1469
|
-
|
1843
|
+
async def _two_factor_enrollment_verify_password_subtask(
|
1844
|
+
self, flow_token: str
|
1845
|
+
) -> tuple[str, list[Subtask]]:
|
1846
|
+
inputs = [
|
1470
1847
|
{
|
1471
1848
|
"subtask_id": "TwoFactorEnrollmentVerifyPasswordSubtask",
|
1472
1849
|
"enter_password": {
|
1473
|
-
"password": self.account.password,
|
1474
1850
|
"link": "next_link",
|
1851
|
+
"password": self.account.password,
|
1475
1852
|
},
|
1476
1853
|
}
|
1477
1854
|
]
|
1478
|
-
return await self.
|
1855
|
+
return await self._complete_subtask(flow_token, inputs)
|
1479
1856
|
|
1480
1857
|
async def _two_factor_enrollment_authentication_app_begin_subtask(
|
1481
1858
|
self, flow_token: str
|
1482
|
-
):
|
1483
|
-
|
1859
|
+
) -> tuple[str, list[Subtask]]:
|
1860
|
+
inputs = [
|
1484
1861
|
{
|
1485
1862
|
"subtask_id": "TwoFactorEnrollmentAuthenticationAppBeginSubtask",
|
1486
1863
|
"action_list": {"link": "next_link"},
|
1487
1864
|
}
|
1488
1865
|
]
|
1489
|
-
return await self.
|
1866
|
+
return await self._complete_subtask(flow_token, inputs)
|
1490
1867
|
|
1491
1868
|
async def _two_factor_enrollment_authentication_app_plain_code_subtask(
|
1492
|
-
self,
|
1493
|
-
|
1869
|
+
self,
|
1870
|
+
flow_token: str,
|
1871
|
+
) -> tuple[str, list[Subtask]]:
|
1494
1872
|
subtask_inputs = [
|
1495
1873
|
{
|
1496
1874
|
"subtask_id": "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask",
|
@@ -1499,12 +1877,12 @@ class Client(BaseClient):
|
|
1499
1877
|
{
|
1500
1878
|
"subtask_id": "TwoFactorEnrollmentAuthenticationAppEnterCodeSubtask",
|
1501
1879
|
"enter_text": {
|
1502
|
-
"text": self.account.get_totp_code(),
|
1503
1880
|
"link": "next_link",
|
1881
|
+
"text": self.account.get_totp_code(),
|
1504
1882
|
},
|
1505
1883
|
},
|
1506
1884
|
]
|
1507
|
-
return await self.
|
1885
|
+
return await self._complete_subtask(flow_token, subtask_inputs)
|
1508
1886
|
|
1509
1887
|
async def _finish_2fa_task(self, flow_token: str):
|
1510
1888
|
subtask_inputs = [
|
@@ -1513,54 +1891,38 @@ class Client(BaseClient):
|
|
1513
1891
|
"cta": {"link": "finish_link"},
|
1514
1892
|
}
|
1515
1893
|
]
|
1516
|
-
|
1894
|
+
await self._complete_subtask(flow_token, subtask_inputs)
|
1517
1895
|
|
1518
1896
|
async def _enable_totp(self):
|
1897
|
+
# fmt: off
|
1519
1898
|
flow_token, subtasks = await self._request_2fa_tasks()
|
1520
|
-
flow_token, subtasks = (
|
1521
|
-
|
1522
|
-
)
|
1523
|
-
flow_token, subtasks = (
|
1524
|
-
await self._two_factor_enrollment_authentication_app_begin_subtask(
|
1525
|
-
flow_token
|
1526
|
-
)
|
1899
|
+
flow_token, subtasks = await self._two_factor_enrollment_verify_password_subtask(
|
1900
|
+
flow_token
|
1527
1901
|
)
|
1902
|
+
flow_token, subtasks = (await self._two_factor_enrollment_authentication_app_begin_subtask(flow_token))
|
1528
1903
|
|
1529
1904
|
for subtask in subtasks:
|
1530
|
-
if
|
1531
|
-
subtask["
|
1532
|
-
== "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask"
|
1533
|
-
):
|
1534
|
-
self.account.totp_secret = subtask["show_code"]["code"]
|
1905
|
+
if subtask.id == "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask":
|
1906
|
+
self.account.totp_secret = subtask.raw_data["show_code"]["code"]
|
1535
1907
|
break
|
1536
1908
|
|
1537
|
-
flow_token, subtasks = (
|
1538
|
-
await self._two_factor_enrollment_authentication_app_plain_code_subtask(
|
1539
|
-
flow_token
|
1540
|
-
)
|
1541
|
-
)
|
1909
|
+
flow_token, subtasks = await self._two_factor_enrollment_authentication_app_plain_code_subtask(flow_token)
|
1542
1910
|
|
1543
1911
|
for subtask in subtasks:
|
1544
|
-
if
|
1545
|
-
subtask["
|
1546
|
-
== "TwoFactorEnrollmentAuthenticationAppCompleteSubtask"
|
1547
|
-
):
|
1548
|
-
result = re.search(
|
1549
|
-
r"\n[a-z0-9]{12}\n", subtask["cta"]["secondary_text"]["text"]
|
1550
|
-
)
|
1912
|
+
if subtask.id == "TwoFactorEnrollmentAuthenticationAppCompleteSubtask":
|
1913
|
+
result = re.search(r"\n[a-z0-9]{12}\n", subtask.raw_data["cta"]["secondary_text"]["text"])
|
1551
1914
|
backup_code = result[0].strip() if result else None
|
1552
1915
|
self.account.backup_code = backup_code
|
1553
1916
|
break
|
1554
1917
|
|
1918
|
+
# fmt: on
|
1555
1919
|
await self._finish_2fa_task(flow_token)
|
1556
1920
|
|
1557
1921
|
async def enable_totp(self):
|
1558
|
-
if not self.account.password:
|
1559
|
-
raise ValueError("Password is required for this action")
|
1560
|
-
|
1561
1922
|
if await self.totp_is_enabled():
|
1562
1923
|
return
|
1563
1924
|
|
1564
|
-
|
1565
|
-
|
1925
|
+
if not self.account.password:
|
1926
|
+
raise ValueError("Password required to enable TOTP")
|
1927
|
+
|
1566
1928
|
await self._enable_totp()
|