tweepy-self 1.9.0__py3-none-any.whl → 1.10.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.10.0.dist-info/METADATA +306 -0
- {tweepy_self-1.9.0.dist-info → tweepy_self-1.10.0.dist-info}/RECORD +10 -10
- twitter/_capsolver/core/serializer.py +1 -1
- twitter/account.py +5 -5
- twitter/base/client.py +3 -3
- twitter/client.py +577 -295
- twitter/errors.py +13 -7
- twitter/models.py +36 -25
- twitter/utils/html.py +6 -2
- tweepy_self-1.9.0.dist-info/METADATA +0 -225
- {tweepy_self-1.9.0.dist-info → tweepy_self-1.10.0.dist-info}/WHEEL +0 -0
twitter/client.py
CHANGED
@@ -2,6 +2,7 @@ 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
|
|
7
8
|
from loguru import logger
|
@@ -29,9 +30,8 @@ from .errors import (
|
|
29
30
|
from .utils import to_json
|
30
31
|
from .base import BaseHTTPClient
|
31
32
|
from .account import Account, AccountStatus
|
32
|
-
from .models import User, Tweet, Media
|
33
|
+
from .models import User, Tweet, Media, Subtask
|
33
34
|
from .utils import (
|
34
|
-
remove_at_sign,
|
35
35
|
parse_oauth_html,
|
36
36
|
parse_unlock_html,
|
37
37
|
tweets_data_from_instructions,
|
@@ -44,7 +44,6 @@ class Client(BaseHTTPClient):
|
|
44
44
|
"authority": "twitter.com",
|
45
45
|
"origin": "https://twitter.com",
|
46
46
|
"x-twitter-active-user": "yes",
|
47
|
-
# 'x-twitter-auth-type': 'OAuth2Session',
|
48
47
|
"x-twitter-client-language": "en",
|
49
48
|
}
|
50
49
|
_GRAPHQL_URL = "https://twitter.com/i/api/graphql"
|
@@ -52,7 +51,7 @@ class Client(BaseHTTPClient):
|
|
52
51
|
"CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
|
53
52
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
54
53
|
"UnfavoriteTweet": "ZYKSe-w7KEslx3JhSIk5LA",
|
55
|
-
"CreateTweet": "
|
54
|
+
"CreateTweet": "v0en1yVV-Ybeek8ClmXwYw",
|
56
55
|
"TweetResultByRestId": "V3vfsYzNEyD9tsf4xoFRgw",
|
57
56
|
"ModerateTweet": "p'jF:GVqCjTcZol0xcBJjw",
|
58
57
|
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
|
@@ -84,6 +83,8 @@ class Client(BaseHTTPClient):
|
|
84
83
|
wait_on_rate_limit: bool = True,
|
85
84
|
capsolver_api_key: str = None,
|
86
85
|
max_unlock_attempts: int = 5,
|
86
|
+
auto_relogin: bool = True,
|
87
|
+
update_account_info_on_startup: bool = True,
|
87
88
|
**session_kwargs,
|
88
89
|
):
|
89
90
|
super().__init__(**session_kwargs)
|
@@ -91,13 +92,21 @@ class Client(BaseHTTPClient):
|
|
91
92
|
self.wait_on_rate_limit = wait_on_rate_limit
|
92
93
|
self.capsolver_api_key = capsolver_api_key
|
93
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
|
94
97
|
|
95
|
-
async def
|
98
|
+
async def __aenter__(self):
|
99
|
+
await self.on_startup()
|
100
|
+
return await super().__aenter__()
|
101
|
+
|
102
|
+
async def _request(
|
96
103
|
self,
|
97
104
|
method,
|
98
105
|
url,
|
106
|
+
*,
|
99
107
|
auth: bool = True,
|
100
108
|
bearer: bool = True,
|
109
|
+
wait_on_rate_limit: bool = None,
|
101
110
|
**kwargs,
|
102
111
|
) -> tuple[requests.Response, Any]:
|
103
112
|
cookies = kwargs["cookies"] = kwargs.get("cookies") or {}
|
@@ -105,6 +114,7 @@ class Client(BaseHTTPClient):
|
|
105
114
|
|
106
115
|
if bearer:
|
107
116
|
headers["authorization"] = f"Bearer {self._BEARER_TOKEN}"
|
117
|
+
# headers["x-twitter-auth-type"] = "OAuth2Session"
|
108
118
|
|
109
119
|
if auth:
|
110
120
|
if not self.account.auth_token:
|
@@ -116,7 +126,8 @@ class Client(BaseHTTPClient):
|
|
116
126
|
headers["x-csrf-token"] = self.account.ct0
|
117
127
|
|
118
128
|
# fmt: off
|
119
|
-
log_message = f"{self.account}
|
129
|
+
log_message = (f"(auth_token={self.account.hidden_auth_token}, id={self.account.id}, username={self.account.username})"
|
130
|
+
f" ==> Request {method} {url}")
|
120
131
|
if kwargs.get('data'): log_message += f"\nRequest data: {kwargs.get('data')}"
|
121
132
|
if kwargs.get('json'): log_message += f"\nRequest data: {kwargs.get('json')}"
|
122
133
|
logger.debug(log_message)
|
@@ -135,23 +146,51 @@ class Client(BaseHTTPClient):
|
|
135
146
|
|
136
147
|
data = response.text
|
137
148
|
# fmt: off
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
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}")
|
142
153
|
# fmt: on
|
143
154
|
|
144
|
-
if
|
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:
|
145
167
|
data = response.json()
|
168
|
+
except json.decoder.JSONDecodeError:
|
169
|
+
pass
|
146
170
|
|
147
|
-
if response.status_code
|
148
|
-
if
|
149
|
-
|
150
|
-
|
151
|
-
if
|
152
|
-
|
153
|
-
|
154
|
-
|
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
|
155
194
|
|
156
195
|
if response.status_code == 400:
|
157
196
|
raise BadRequest(response, data)
|
@@ -168,10 +207,6 @@ class Client(BaseHTTPClient):
|
|
168
207
|
if response.status_code == 403:
|
169
208
|
exc = Forbidden(response, data)
|
170
209
|
|
171
|
-
if 353 in exc.api_codes and "ct0" in response.cookies:
|
172
|
-
self.account.ct0 = response.cookies["ct0"]
|
173
|
-
return await self.request(method, url, auth, bearer, **kwargs)
|
174
|
-
|
175
210
|
if 64 in exc.api_codes:
|
176
211
|
self.account.status = AccountStatus.SUSPENDED
|
177
212
|
raise Suspended(exc, self.account)
|
@@ -186,52 +221,83 @@ class Client(BaseHTTPClient):
|
|
186
221
|
raise ConsentLocked(exc, self.account)
|
187
222
|
|
188
223
|
self.account.status = AccountStatus.LOCKED
|
189
|
-
|
190
|
-
raise Locked(exc, self.account)
|
191
|
-
|
192
|
-
await self.unlock()
|
193
|
-
return await self.request(method, url, auth, bearer, **kwargs)
|
224
|
+
raise Locked(exc, self.account)
|
194
225
|
|
195
226
|
raise exc
|
196
227
|
|
197
228
|
if response.status_code == 404:
|
198
229
|
raise NotFound(response, data)
|
199
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
|
+
|
200
254
|
if response.status_code >= 500:
|
201
255
|
raise ServerError(response, data)
|
202
256
|
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
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)
|
208
268
|
|
209
|
-
|
210
|
-
|
211
|
-
raise
|
269
|
+
except Locked:
|
270
|
+
if not self.capsolver_api_key or not auto_unlock:
|
271
|
+
raise
|
212
272
|
|
213
|
-
|
214
|
-
|
215
|
-
if (
|
216
|
-
error_data.get("code") == 326
|
217
|
-
and error_data.get("bounce_location") == "/i/flow/consent_flow"
|
218
|
-
):
|
219
|
-
self.account.status = AccountStatus.CONSENT_LOCKED
|
220
|
-
raise ConsentLocked(exc, self.account)
|
273
|
+
await self.unlock()
|
274
|
+
return await self._request(method, url, **kwargs)
|
221
275
|
|
222
|
-
|
223
|
-
|
224
|
-
|
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
|
225
285
|
|
226
|
-
|
227
|
-
|
286
|
+
await self.relogin()
|
287
|
+
return await self._request(method, url, **kwargs)
|
228
288
|
|
229
|
-
|
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
|
230
294
|
|
231
|
-
|
232
|
-
|
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()
|
233
299
|
|
234
|
-
async def
|
300
|
+
async def _request_oauth2_auth_code(
|
235
301
|
self,
|
236
302
|
client_id: str,
|
237
303
|
code_challenge: str,
|
@@ -255,7 +321,7 @@ class Client(BaseHTTPClient):
|
|
255
321
|
auth_code = response_json["auth_code"]
|
256
322
|
return auth_code
|
257
323
|
|
258
|
-
async def
|
324
|
+
async def _confirm_oauth2(self, auth_code: str):
|
259
325
|
data = {
|
260
326
|
"approval": "true",
|
261
327
|
"code": auth_code,
|
@@ -268,7 +334,7 @@ class Client(BaseHTTPClient):
|
|
268
334
|
data=data,
|
269
335
|
)
|
270
336
|
|
271
|
-
async def
|
337
|
+
async def oauth2(
|
272
338
|
self,
|
273
339
|
client_id: str,
|
274
340
|
code_challenge: str,
|
@@ -284,15 +350,13 @@ class Client(BaseHTTPClient):
|
|
284
350
|
Привязка (бинд, линк) приложения.
|
285
351
|
|
286
352
|
:param client_id: Идентификатор клиента, используемый для OAuth.
|
287
|
-
:param code_challenge: Код-вызов, используемый для PKCE (Proof Key for Code Exchange).
|
288
353
|
:param state: Уникальная строка состояния для предотвращения CSRF-атак.
|
289
354
|
:param redirect_uri: URI перенаправления, на который будет отправлен ответ.
|
290
|
-
:param code_challenge_method: Метод, используемый для преобразования code_verifier в code_challenge.
|
291
355
|
:param scope: Строка областей доступа, запрашиваемых у пользователя.
|
292
356
|
:param response_type: Тип ответа, который ожидается от сервера авторизации.
|
293
357
|
:return: Код авторизации (привязки).
|
294
358
|
"""
|
295
|
-
auth_code = await self.
|
359
|
+
auth_code = await self._request_oauth2_auth_code(
|
296
360
|
client_id,
|
297
361
|
code_challenge,
|
298
362
|
state,
|
@@ -301,7 +365,7 @@ class Client(BaseHTTPClient):
|
|
301
365
|
scope,
|
302
366
|
response_type,
|
303
367
|
)
|
304
|
-
await self.
|
368
|
+
await self._confirm_oauth2(auth_code)
|
305
369
|
return auth_code
|
306
370
|
|
307
371
|
async def _oauth(self, oauth_token: str, **oauth_params) -> requests.Response:
|
@@ -356,14 +420,13 @@ class Client(BaseHTTPClient):
|
|
356
420
|
|
357
421
|
return authenticity_token, redirect_url
|
358
422
|
|
359
|
-
async def
|
423
|
+
async def _update_account_username(self):
|
360
424
|
url = "https://twitter.com/i/api/1.1/account/settings.json"
|
361
425
|
response, response_json = await self.request("POST", url)
|
362
426
|
self.account.username = response_json["screen_name"]
|
363
427
|
|
364
|
-
async def
|
428
|
+
async def _request_user_by_username(self, username: str) -> User | None:
|
365
429
|
url, query_id = self._action_to_url("UserByScreenName")
|
366
|
-
username = remove_at_sign(username)
|
367
430
|
variables = {
|
368
431
|
"screen_name": username,
|
369
432
|
"withSafetyModeUserFields": True,
|
@@ -389,31 +452,76 @@ class Client(BaseHTTPClient):
|
|
389
452
|
"fieldToggles": to_json(field_toggles),
|
390
453
|
}
|
391
454
|
response, data = await self.request("GET", url, params=params)
|
455
|
+
if not data["data"]:
|
456
|
+
return None
|
392
457
|
return User.from_raw_data(data["data"]["user"]["result"])
|
393
458
|
|
394
|
-
async def
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
users = await self.request_users((user_id,))
|
402
|
-
user = users[user_id]
|
403
|
-
else:
|
404
|
-
if not username:
|
405
|
-
if not self.account.username:
|
406
|
-
await self.request_and_set_username()
|
407
|
-
username = self.account.username
|
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()
|
408
466
|
|
409
|
-
|
467
|
+
user = await self._request_user_by_username(username)
|
410
468
|
|
411
|
-
if
|
469
|
+
if user and user.username == self.account.username:
|
412
470
|
self.account.update(**user.model_dump())
|
413
|
-
|
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
|
414
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]
|
415
508
|
return user
|
416
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)
|
524
|
+
|
417
525
|
async def upload_image(
|
418
526
|
self,
|
419
527
|
image: bytes,
|
@@ -429,15 +537,13 @@ class Client(BaseHTTPClient):
|
|
429
537
|
:return: Media
|
430
538
|
"""
|
431
539
|
url = "https://upload.twitter.com/1.1/media/upload.json"
|
432
|
-
|
433
540
|
payload = {"media_data": base64.b64encode(image)}
|
434
|
-
|
435
541
|
for attempt in range(attempts):
|
436
542
|
try:
|
437
543
|
response, data = await self.request(
|
438
544
|
"POST", url, data=payload, timeout=timeout
|
439
545
|
)
|
440
|
-
return Media
|
546
|
+
return Media(**data)
|
441
547
|
except (HTTPException, requests.errors.RequestsError) as exc:
|
442
548
|
if (
|
443
549
|
attempt < attempts - 1
|
@@ -541,6 +647,8 @@ class Client(BaseHTTPClient):
|
|
541
647
|
"""
|
542
648
|
Repost (retweet)
|
543
649
|
|
650
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
651
|
+
|
544
652
|
:return: Tweet
|
545
653
|
"""
|
546
654
|
return await self._repost_or_search_duplicate(
|
@@ -548,9 +656,18 @@ class Client(BaseHTTPClient):
|
|
548
656
|
)
|
549
657
|
|
550
658
|
async def like(self, tweet_id: int) -> bool:
|
551
|
-
|
552
|
-
|
553
|
-
|
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"
|
554
671
|
|
555
672
|
async def unlike(self, tweet_id: int) -> dict:
|
556
673
|
response_json = await self._interact_with_tweet("UnfavoriteTweet", tweet_id)
|
@@ -597,47 +714,50 @@ class Client(BaseHTTPClient):
|
|
597
714
|
attachment_url: str = None,
|
598
715
|
) -> Tweet:
|
599
716
|
url, query_id = self._action_to_url("CreateTweet")
|
600
|
-
|
601
|
-
"
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
"semantic_annotation_ids": [],
|
606
|
-
},
|
607
|
-
"features": {
|
608
|
-
"tweetypie_unmention_optimization_enabled": True,
|
609
|
-
"responsive_web_edit_tweet_api_enabled": True,
|
610
|
-
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
|
611
|
-
"view_counts_everywhere_api_enabled": True,
|
612
|
-
"longform_notetweets_consumption_enabled": True,
|
613
|
-
"tweet_awards_web_tipping_enabled": False,
|
614
|
-
"longform_notetweets_rich_text_read_enabled": True,
|
615
|
-
"longform_notetweets_inline_media_enabled": True,
|
616
|
-
"responsive_web_graphql_exclude_directive_enabled": True,
|
617
|
-
"verified_phone_label_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": False,
|
621
|
-
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
622
|
-
"responsive_web_graphql_timeline_navigation_enabled": True,
|
623
|
-
"responsive_web_enhance_cards_enabled": False,
|
624
|
-
"responsive_web_twitter_article_tweet_consumption_enabled": False,
|
625
|
-
"responsive_web_media_download_video_enabled": False,
|
626
|
-
},
|
627
|
-
"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": [],
|
628
722
|
}
|
629
723
|
if attachment_url:
|
630
|
-
|
724
|
+
variables["attachment_url"] = attachment_url
|
631
725
|
if tweet_id_to_reply:
|
632
|
-
|
726
|
+
variables["reply"] = {
|
633
727
|
"in_reply_to_tweet_id": str(tweet_id_to_reply),
|
634
728
|
"exclude_reply_user_ids": [],
|
635
729
|
}
|
636
730
|
if media_id:
|
637
|
-
|
731
|
+
variables["media"]["media_entities"].append(
|
638
732
|
{"media_id": str(media_id), "tagged_users": []}
|
639
733
|
)
|
640
|
-
|
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
|
+
}
|
641
761
|
response, response_json = await self.request("POST", url, json=payload)
|
642
762
|
tweet = Tweet.from_raw_data(
|
643
763
|
response_json["data"]["create_tweet"]["tweet_results"]["result"]
|
@@ -689,6 +809,11 @@ class Client(BaseHTTPClient):
|
|
689
809
|
media_id: int | str = None,
|
690
810
|
search_duplicate: bool = True,
|
691
811
|
) -> Tweet:
|
812
|
+
"""
|
813
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
814
|
+
|
815
|
+
:return: Tweet
|
816
|
+
"""
|
692
817
|
return await self._tweet_or_search_duplicate(
|
693
818
|
text,
|
694
819
|
media_id=media_id,
|
@@ -703,6 +828,11 @@ class Client(BaseHTTPClient):
|
|
703
828
|
media_id: int | str = None,
|
704
829
|
search_duplicate: bool = True,
|
705
830
|
) -> Tweet:
|
831
|
+
"""
|
832
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
833
|
+
|
834
|
+
:return: Tweet
|
835
|
+
"""
|
706
836
|
return await self._tweet_or_search_duplicate(
|
707
837
|
text,
|
708
838
|
media_id=media_id,
|
@@ -718,6 +848,11 @@ class Client(BaseHTTPClient):
|
|
718
848
|
media_id: int | str = None,
|
719
849
|
search_duplicate: bool = True,
|
720
850
|
) -> Tweet:
|
851
|
+
"""
|
852
|
+
Иногда может вернуть ошибку 404 (Not Found), если плохой прокси или по другим неизвестным причинам
|
853
|
+
|
854
|
+
:return: Tweet
|
855
|
+
"""
|
721
856
|
return await self._tweet_or_search_duplicate(
|
722
857
|
text,
|
723
858
|
media_id=media_id,
|
@@ -742,8 +877,12 @@ class Client(BaseHTTPClient):
|
|
742
877
|
response, response_json = await self.request("POST", url, params=params)
|
743
878
|
return response_json
|
744
879
|
|
745
|
-
async def
|
746
|
-
self,
|
880
|
+
async def _request_users_by_action(
|
881
|
+
self,
|
882
|
+
action: str,
|
883
|
+
user_id: int | str,
|
884
|
+
count: int,
|
885
|
+
cursor: str = None,
|
747
886
|
) -> list[User]:
|
748
887
|
url, query_id = self._action_to_url(action)
|
749
888
|
variables = {
|
@@ -751,6 +890,8 @@ class Client(BaseHTTPClient):
|
|
751
890
|
"count": count,
|
752
891
|
"includePromotedContent": False,
|
753
892
|
}
|
893
|
+
if cursor:
|
894
|
+
variables["cursor"] = cursor
|
754
895
|
features = {
|
755
896
|
"rweb_lists_timeline_redesign_enabled": True,
|
756
897
|
"responsive_web_graphql_exclude_directive_enabled": True,
|
@@ -793,53 +934,46 @@ class Client(BaseHTTPClient):
|
|
793
934
|
return users
|
794
935
|
|
795
936
|
async def request_followers(
|
796
|
-
self,
|
937
|
+
self,
|
938
|
+
user_id: int | str = None,
|
939
|
+
count: int = 20,
|
940
|
+
cursor: str = None,
|
797
941
|
) -> list[User]:
|
798
942
|
"""
|
799
943
|
:param user_id: Текущий пользователь, если не передан ID иного пользователя.
|
800
944
|
:param count: Количество подписчиков.
|
801
945
|
"""
|
802
946
|
if user_id:
|
803
|
-
return await self.
|
947
|
+
return await self._request_users_by_action(
|
948
|
+
"Followers", user_id, count, cursor
|
949
|
+
)
|
804
950
|
else:
|
805
951
|
if not self.account.id:
|
806
|
-
await self.
|
807
|
-
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
|
+
)
|
808
956
|
|
809
957
|
async def request_followings(
|
810
|
-
self,
|
958
|
+
self,
|
959
|
+
user_id: int | str = None,
|
960
|
+
count: int = 20,
|
961
|
+
cursor: str = None,
|
811
962
|
) -> list[User]:
|
812
963
|
"""
|
813
964
|
:param user_id: Текущий пользователь, если не передан ID иного пользователя.
|
814
965
|
:param count: Количество подписчиков.
|
815
966
|
"""
|
816
967
|
if user_id:
|
817
|
-
return await self.
|
968
|
+
return await self._request_users_by_action(
|
969
|
+
"Following", user_id, count, cursor
|
970
|
+
)
|
818
971
|
else:
|
819
972
|
if not self.account.id:
|
820
|
-
await self.
|
821
|
-
return await self.
|
822
|
-
|
823
|
-
|
824
|
-
self, user_ids: Iterable[str | int]
|
825
|
-
) -> dict[int : User | Account]:
|
826
|
-
url, query_id = self._action_to_url("UsersByRestIds")
|
827
|
-
variables = {"userIds": list({str(user_id) for user_id in user_ids})}
|
828
|
-
features = {
|
829
|
-
"responsive_web_graphql_exclude_directive_enabled": True,
|
830
|
-
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
|
831
|
-
"responsive_web_graphql_timeline_navigation_enabled": True,
|
832
|
-
"verified_phone_label_enabled": False,
|
833
|
-
}
|
834
|
-
query = {"variables": variables, "features": features}
|
835
|
-
response, data = await self.request("GET", url, params=query)
|
836
|
-
|
837
|
-
users = {}
|
838
|
-
for user_data in data["data"]["users"]:
|
839
|
-
user_data = user_data["result"]
|
840
|
-
user = User.from_raw_data(user_data)
|
841
|
-
users[user.id] = user
|
842
|
-
return users
|
973
|
+
await self.update_account_info()
|
974
|
+
return await self._request_users_by_action(
|
975
|
+
"Following", self.account.id, count, cursor
|
976
|
+
)
|
843
977
|
|
844
978
|
async def _request_tweet(self, tweet_id: int | str) -> Tweet:
|
845
979
|
url, query_id = self._action_to_url("TweetDetail")
|
@@ -882,7 +1016,9 @@ class Client(BaseHTTPClient):
|
|
882
1016
|
tweet_data = tweets_data_from_instructions(instructions)[0]
|
883
1017
|
return Tweet.from_raw_data(tweet_data)
|
884
1018
|
|
885
|
-
async def _request_tweets(
|
1019
|
+
async def _request_tweets(
|
1020
|
+
self, user_id: int | str, count: int = 20, cursor: str = None
|
1021
|
+
) -> list[Tweet]:
|
886
1022
|
url, query_id = self._action_to_url("UserTweets")
|
887
1023
|
variables = {
|
888
1024
|
"userId": str(user_id),
|
@@ -892,6 +1028,8 @@ class Client(BaseHTTPClient):
|
|
892
1028
|
"withVoice": True,
|
893
1029
|
"withV2Timeline": True,
|
894
1030
|
}
|
1031
|
+
if cursor:
|
1032
|
+
variables["cursor"] = cursor
|
895
1033
|
features = {
|
896
1034
|
"responsive_web_graphql_exclude_directive_enabled": True,
|
897
1035
|
"verified_phone_label_enabled": False,
|
@@ -928,16 +1066,14 @@ class Client(BaseHTTPClient):
|
|
928
1066
|
return await self._request_tweet(tweet_id)
|
929
1067
|
|
930
1068
|
async def request_tweets(
|
931
|
-
self,
|
932
|
-
user_id: int | str = None,
|
933
|
-
count: int = 20,
|
1069
|
+
self, user_id: int | str = None, count: int = 20, cursor: str = None
|
934
1070
|
) -> list[Tweet]:
|
935
1071
|
if not user_id:
|
936
1072
|
if not self.account.id:
|
937
|
-
await self.
|
1073
|
+
await self.update_account_info()
|
938
1074
|
user_id = self.account.id
|
939
1075
|
|
940
|
-
return await self._request_tweets(user_id, count)
|
1076
|
+
return await self._request_tweets(user_id, count, cursor)
|
941
1077
|
|
942
1078
|
async def _update_profile_image(
|
943
1079
|
self, type: Literal["banner", "image"], media_id: str | int
|
@@ -1003,9 +1139,6 @@ class Client(BaseHTTPClient):
|
|
1003
1139
|
}
|
1004
1140
|
response, data = await self.request("POST", url, data=payload)
|
1005
1141
|
changed = data["status"] == "ok"
|
1006
|
-
# TODO Делать это автоматически в методе request
|
1007
|
-
auth_token = response.cookies.get("auth_token", domain=".twitter.com")
|
1008
|
-
self.account.auth_token = auth_token
|
1009
1142
|
self.account.password = password
|
1010
1143
|
return changed
|
1011
1144
|
|
@@ -1023,7 +1156,6 @@ class Client(BaseHTTPClient):
|
|
1023
1156
|
raise ValueError("Specify at least one param")
|
1024
1157
|
|
1025
1158
|
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
1026
|
-
# headers = {"content-type": "application/x-www-form-urlencoded"}
|
1027
1159
|
# Создаем словарь data, включая в него только те ключи, для которых значения не равны None
|
1028
1160
|
payload = {
|
1029
1161
|
k: v
|
@@ -1035,7 +1167,6 @@ class Client(BaseHTTPClient):
|
|
1035
1167
|
]
|
1036
1168
|
if v is not None
|
1037
1169
|
}
|
1038
|
-
# response, response_json = await self.request("POST", url, headers=headers, data=payload)
|
1039
1170
|
response, data = await self.request("POST", url, data=payload)
|
1040
1171
|
# Проверяем, что все переданные параметры соответствуют полученным
|
1041
1172
|
updated = all(
|
@@ -1045,13 +1176,14 @@ class Client(BaseHTTPClient):
|
|
1045
1176
|
updated &= URL(website) == URL(
|
1046
1177
|
data["entities"]["url"]["urls"][0]["expanded_url"]
|
1047
1178
|
)
|
1048
|
-
await self.
|
1179
|
+
await self.update_account_info()
|
1049
1180
|
return updated
|
1050
1181
|
|
1051
1182
|
async def establish_status(self):
|
1052
1183
|
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
1053
1184
|
try:
|
1054
|
-
await self.request("POST", url)
|
1185
|
+
await self.request("POST", url, auto_unlock=False, auto_relogin=False)
|
1186
|
+
self.account.status = AccountStatus.GOOD
|
1055
1187
|
except BadAccount:
|
1056
1188
|
pass
|
1057
1189
|
|
@@ -1064,17 +1196,14 @@ class Client(BaseHTTPClient):
|
|
1064
1196
|
year_visibility: Literal["self"] = "self",
|
1065
1197
|
) -> bool:
|
1066
1198
|
url = "https://twitter.com/i/api/1.1/account/update_profile.json"
|
1067
|
-
|
1068
|
-
data = {
|
1199
|
+
payload = {
|
1069
1200
|
"birthdate_day": day,
|
1070
1201
|
"birthdate_month": month,
|
1071
1202
|
"birthdate_year": year,
|
1072
1203
|
"birthdate_visibility": visibility,
|
1073
1204
|
"birthdate_year_visibility": year_visibility,
|
1074
1205
|
}
|
1075
|
-
response, response_json = await self.request(
|
1076
|
-
"POST", url, headers=headers, data=data
|
1077
|
-
)
|
1206
|
+
response, response_json = await self.request("POST", url, json=payload)
|
1078
1207
|
birthdate_data = response_json["extended_profile"]["birthdate"]
|
1079
1208
|
updated = all(
|
1080
1209
|
(
|
@@ -1105,6 +1234,20 @@ class Client(BaseHTTPClient):
|
|
1105
1234
|
event_data = data["event"]
|
1106
1235
|
return event_data # TODO Возвращать модель, а не словарь
|
1107
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}
|
1247
|
+
response, response_json = await self.request("POST", url, json=payload)
|
1248
|
+
event_data = response_json["event"]
|
1249
|
+
return event_data
|
1250
|
+
|
1108
1251
|
async def request_messages(self) -> list[dict]:
|
1109
1252
|
"""
|
1110
1253
|
:return: Messages data
|
@@ -1170,6 +1313,8 @@ class Client(BaseHTTPClient):
|
|
1170
1313
|
payload["verification_string"] = verification_string
|
1171
1314
|
payload["language_code"] = "en"
|
1172
1315
|
|
1316
|
+
# TODO ui_metrics
|
1317
|
+
|
1173
1318
|
return await self.request("POST", self._CAPTCHA_URL, data=payload, bearer=False)
|
1174
1319
|
|
1175
1320
|
async def unlock(self):
|
@@ -1183,9 +1328,23 @@ class Client(BaseHTTPClient):
|
|
1183
1328
|
needs_unlock,
|
1184
1329
|
start_button,
|
1185
1330
|
finish_button,
|
1331
|
+
delete_button,
|
1186
1332
|
) = parse_unlock_html(html)
|
1187
1333
|
attempt = 1
|
1188
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
|
+
|
1189
1348
|
if start_button or finish_button:
|
1190
1349
|
response, html = await self._confirm_unlock(
|
1191
1350
|
authenticity_token, assignment_token
|
@@ -1196,6 +1355,7 @@ class Client(BaseHTTPClient):
|
|
1196
1355
|
needs_unlock,
|
1197
1356
|
start_button,
|
1198
1357
|
finish_button,
|
1358
|
+
delete_button,
|
1199
1359
|
) = parse_unlock_html(html)
|
1200
1360
|
|
1201
1361
|
funcaptcha = {
|
@@ -1213,8 +1373,20 @@ class Client(BaseHTTPClient):
|
|
1213
1373
|
else:
|
1214
1374
|
funcaptcha["captcha_type"] = FunCaptchaTypeEnm.FunCaptchaTaskProxyLess
|
1215
1375
|
|
1216
|
-
while needs_unlock:
|
1376
|
+
while needs_unlock and attempt <= self.max_unlock_attempts:
|
1217
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
|
+
|
1218
1390
|
token = solution.solution["token"]
|
1219
1391
|
response, html = await self._confirm_unlock(
|
1220
1392
|
authenticity_token,
|
@@ -1222,12 +1394,8 @@ class Client(BaseHTTPClient):
|
|
1222
1394
|
verification_string=token,
|
1223
1395
|
)
|
1224
1396
|
|
1225
|
-
if
|
1226
|
-
|
1227
|
-
or response.url == "https://twitter.com/?lang=en"
|
1228
|
-
):
|
1229
|
-
await self.establish_status()
|
1230
|
-
return
|
1397
|
+
if response.url == "https://twitter.com/?lang=en":
|
1398
|
+
break
|
1231
1399
|
|
1232
1400
|
(
|
1233
1401
|
authenticity_token,
|
@@ -1235,6 +1403,7 @@ class Client(BaseHTTPClient):
|
|
1235
1403
|
needs_unlock,
|
1236
1404
|
start_button,
|
1237
1405
|
finish_button,
|
1406
|
+
delete_button,
|
1238
1407
|
) = parse_unlock_html(html)
|
1239
1408
|
|
1240
1409
|
if finish_button:
|
@@ -1247,22 +1416,58 @@ class Client(BaseHTTPClient):
|
|
1247
1416
|
needs_unlock,
|
1248
1417
|
start_button,
|
1249
1418
|
finish_button,
|
1419
|
+
delete_button,
|
1250
1420
|
) = parse_unlock_html(html)
|
1251
1421
|
|
1252
1422
|
attempt += 1
|
1253
1423
|
|
1254
|
-
|
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]]:
|
1255
1432
|
"""
|
1256
|
-
:return: flow_token
|
1433
|
+
:return: flow_token and subtasks
|
1257
1434
|
"""
|
1258
1435
|
url = "https://api.twitter.com/1.1/onboarding/task.json"
|
1259
|
-
response,
|
1260
|
-
|
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
|
1261
1454
|
|
1262
|
-
async def
|
1263
|
-
|
1264
|
-
:
|
1265
|
-
|
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)
|
1469
|
+
|
1470
|
+
async def _request_login_tasks(self) -> tuple[str, list[Subtask]]:
|
1266
1471
|
params = {
|
1267
1472
|
"flow_name": "login",
|
1268
1473
|
}
|
@@ -1317,84 +1522,89 @@ class Client(BaseHTTPClient):
|
|
1317
1522
|
"web_modal": 1,
|
1318
1523
|
},
|
1319
1524
|
}
|
1320
|
-
return await self.
|
1321
|
-
|
1322
|
-
async def _send_task(self, flow_token: str, subtask_inputs: list[dict], **kwargs):
|
1323
|
-
payload = kwargs["json"] = kwargs.get("json") or {}
|
1324
|
-
payload.update(
|
1325
|
-
{
|
1326
|
-
"flow_token": flow_token,
|
1327
|
-
"subtask_inputs": subtask_inputs,
|
1328
|
-
}
|
1329
|
-
)
|
1330
|
-
return await self._task(**kwargs)
|
1331
|
-
|
1332
|
-
async def _finish_task(self, flow_token):
|
1333
|
-
payload = {
|
1334
|
-
"flow_token": flow_token,
|
1335
|
-
"subtask_inputs": [],
|
1336
|
-
}
|
1337
|
-
return await self._task(json=payload)
|
1525
|
+
return await self._send_raw_subtask(params=params, json=payload, auth=False)
|
1338
1526
|
|
1339
|
-
async def _login_enter_user_identifier(self, flow_token):
|
1340
|
-
|
1527
|
+
async def _login_enter_user_identifier(self, flow_token: str):
|
1528
|
+
inputs = [
|
1341
1529
|
{
|
1342
1530
|
"subtask_id": "LoginEnterUserIdentifierSSO",
|
1343
1531
|
"settings_list": {
|
1532
|
+
"link": "next_link",
|
1344
1533
|
"setting_responses": [
|
1345
1534
|
{
|
1346
1535
|
"key": "user_identifier",
|
1347
1536
|
"response_data": {
|
1348
1537
|
"text_data": {
|
1349
|
-
"result": self.account.
|
1350
|
-
or self.account.
|
1538
|
+
"result": self.account.username
|
1539
|
+
or self.account.email
|
1351
1540
|
}
|
1352
1541
|
},
|
1353
1542
|
}
|
1354
1543
|
],
|
1355
|
-
"link": "next_link",
|
1356
1544
|
},
|
1357
1545
|
}
|
1358
1546
|
]
|
1359
|
-
return await self.
|
1547
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1360
1548
|
|
1361
|
-
async def _login_enter_password(self, flow_token):
|
1362
|
-
|
1549
|
+
async def _login_enter_password(self, flow_token: str):
|
1550
|
+
inputs = [
|
1363
1551
|
{
|
1364
1552
|
"subtask_id": "LoginEnterPassword",
|
1365
1553
|
"enter_password": {
|
1366
|
-
"password": self.account.password,
|
1367
1554
|
"link": "next_link",
|
1555
|
+
"password": self.account.password,
|
1368
1556
|
},
|
1369
1557
|
}
|
1370
1558
|
]
|
1371
|
-
return await self.
|
1559
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1372
1560
|
|
1373
1561
|
async def _account_duplication_check(self, flow_token):
|
1374
|
-
|
1562
|
+
inputs = [
|
1375
1563
|
{
|
1376
1564
|
"subtask_id": "AccountDuplicationCheck",
|
1377
1565
|
"check_logged_in_account": {"link": "AccountDuplicationCheck_false"},
|
1378
1566
|
}
|
1379
1567
|
]
|
1380
|
-
return await self.
|
1381
|
-
|
1382
|
-
async def _login_two_factor_auth_challenge(self, flow_token):
|
1383
|
-
if not self.account.totp_secret:
|
1384
|
-
raise TwitterException(
|
1385
|
-
f"Failed to login. Task id: LoginTwoFactorAuthChallenge"
|
1386
|
-
)
|
1568
|
+
return await self._complete_subtask(flow_token, inputs, auth=False)
|
1387
1569
|
|
1388
|
-
|
1570
|
+
async def _login_two_factor_auth_challenge(self, flow_token, value: str):
|
1571
|
+
inputs = [
|
1389
1572
|
{
|
1390
1573
|
"subtask_id": "LoginTwoFactorAuthChallenge",
|
1391
1574
|
"enter_text": {
|
1392
|
-
"text": self.account.get_totp_code(),
|
1393
1575
|
"link": "next_link",
|
1576
|
+
"text": value,
|
1577
|
+
},
|
1578
|
+
}
|
1579
|
+
]
|
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],
|
1394
1591
|
},
|
1395
1592
|
}
|
1396
1593
|
]
|
1397
|
-
return await self.
|
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)
|
1398
1608
|
|
1399
1609
|
async def _viewer(self):
|
1400
1610
|
url, query_id = self._action_to_url("Viewer")
|
@@ -1411,11 +1621,11 @@ class Client(BaseHTTPClient):
|
|
1411
1621
|
}
|
1412
1622
|
variables = {"withCommunitiesMemberships": True}
|
1413
1623
|
params = {
|
1414
|
-
"features":
|
1415
|
-
"fieldToggles":
|
1416
|
-
"variables":
|
1624
|
+
"features": features,
|
1625
|
+
"fieldToggles": field_toggles,
|
1626
|
+
"variables": variables,
|
1417
1627
|
}
|
1418
|
-
return self.request("GET", url, params=params)
|
1628
|
+
return await self.request("GET", url, params=params)
|
1419
1629
|
|
1420
1630
|
async def _request_guest_token(self) -> str:
|
1421
1631
|
"""
|
@@ -1430,66 +1640,151 @@ class Client(BaseHTTPClient):
|
|
1430
1640
|
guest_token = re.search(r"gt\s?=\s?\d+", response.text)[0].split("=")[1]
|
1431
1641
|
return guest_token
|
1432
1642
|
|
1433
|
-
async def _login(self):
|
1643
|
+
async def _login(self) -> bool:
|
1644
|
+
update_backup_code = False
|
1645
|
+
|
1434
1646
|
guest_token = await self._request_guest_token()
|
1435
1647
|
self._session.headers["X-Guest-Token"] = guest_token
|
1436
1648
|
|
1437
|
-
# Можно не устанавливать, так как твиттер сам вернет этот токен
|
1438
|
-
# self._session.cookies["gt"] = guest_token
|
1439
|
-
|
1440
1649
|
flow_token, subtasks = await self._request_login_tasks()
|
1441
1650
|
for _ in range(2):
|
1442
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
|
+
|
1443
1658
|
flow_token, subtasks = await self._login_enter_password(flow_token)
|
1444
1659
|
flow_token, subtasks = await self._account_duplication_check(flow_token)
|
1445
1660
|
|
1446
|
-
|
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
|
+
)
|
1667
|
+
|
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
|
+
)
|
1447
1674
|
|
1448
|
-
|
1449
|
-
|
1450
|
-
|
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}
|
1451
1692
|
|
1452
1693
|
if "LoginTwoFactorAuthChallenge" in subtask_ids:
|
1453
|
-
|
1454
|
-
|
1455
|
-
|
1456
|
-
|
1457
|
-
# TODO Делать это автоматически в методе request
|
1458
|
-
self.account.auth_token = self._session.cookies["auth_token"]
|
1459
|
-
self.account.ct0 = self._session.cookies["ct0"]
|
1694
|
+
if not self.account.totp_secret:
|
1695
|
+
raise TwitterException(
|
1696
|
+
f"Failed to login. Task id: LoginTwoFactorAuthChallenge. No totp_secret!"
|
1697
|
+
)
|
1460
1698
|
|
1461
|
-
|
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
|
1462
1735
|
|
1463
|
-
|
1464
|
-
|
1465
|
-
await self.establish_status()
|
1466
|
-
if self.account.status != "BAD_TOKEN":
|
1467
|
-
return
|
1736
|
+
await self._complete_subtask(flow_token, [])
|
1737
|
+
return update_backup_code
|
1468
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
|
+
"""
|
1469
1746
|
if not self.account.email and not self.account.username:
|
1470
1747
|
raise ValueError("No email or username")
|
1471
1748
|
|
1472
1749
|
if not self.account.password:
|
1473
1750
|
raise ValueError("No password")
|
1474
1751
|
|
1475
|
-
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
|
+
|
1476
1763
|
await self.establish_status()
|
1477
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
|
+
|
1478
1773
|
async def totp_is_enabled(self):
|
1479
1774
|
if not self.account.id:
|
1480
|
-
await self.
|
1775
|
+
await self.update_account_info()
|
1481
1776
|
|
1482
1777
|
url = f"https://twitter.com/i/api/1.1/strato/column/User/{self.account.id}/account-security/twoFactorAuthSettings2"
|
1483
1778
|
response, data = await self.request("GET", url)
|
1484
|
-
|
1485
|
-
|
1486
|
-
|
1779
|
+
# fmt: off
|
1780
|
+
return "Totp" in [method_data["twoFactorType"] for method_data in data["methods"]]
|
1781
|
+
# fmt: on
|
1487
1782
|
|
1488
|
-
async def _request_2fa_tasks(self):
|
1783
|
+
async def _request_2fa_tasks(self) -> tuple[str, list[Subtask]]:
|
1489
1784
|
"""
|
1490
|
-
:return: flow_token,
|
1785
|
+
:return: flow_token, tasks
|
1491
1786
|
"""
|
1492
|
-
|
1787
|
+
query = {
|
1493
1788
|
"flow_name": "two-factor-auth-app-enrollment",
|
1494
1789
|
}
|
1495
1790
|
payload = {
|
@@ -1543,34 +1838,37 @@ class Client(BaseHTTPClient):
|
|
1543
1838
|
"web_modal": 1,
|
1544
1839
|
},
|
1545
1840
|
}
|
1546
|
-
return await self.
|
1841
|
+
return await self._send_raw_subtask(params=query, json=payload)
|
1547
1842
|
|
1548
|
-
async def _two_factor_enrollment_verify_password_subtask(
|
1549
|
-
|
1843
|
+
async def _two_factor_enrollment_verify_password_subtask(
|
1844
|
+
self, flow_token: str
|
1845
|
+
) -> tuple[str, list[Subtask]]:
|
1846
|
+
inputs = [
|
1550
1847
|
{
|
1551
1848
|
"subtask_id": "TwoFactorEnrollmentVerifyPasswordSubtask",
|
1552
1849
|
"enter_password": {
|
1553
|
-
"password": self.account.password,
|
1554
1850
|
"link": "next_link",
|
1851
|
+
"password": self.account.password,
|
1555
1852
|
},
|
1556
1853
|
}
|
1557
1854
|
]
|
1558
|
-
return await self.
|
1855
|
+
return await self._complete_subtask(flow_token, inputs)
|
1559
1856
|
|
1560
1857
|
async def _two_factor_enrollment_authentication_app_begin_subtask(
|
1561
1858
|
self, flow_token: str
|
1562
|
-
):
|
1563
|
-
|
1859
|
+
) -> tuple[str, list[Subtask]]:
|
1860
|
+
inputs = [
|
1564
1861
|
{
|
1565
1862
|
"subtask_id": "TwoFactorEnrollmentAuthenticationAppBeginSubtask",
|
1566
1863
|
"action_list": {"link": "next_link"},
|
1567
1864
|
}
|
1568
1865
|
]
|
1569
|
-
return await self.
|
1866
|
+
return await self._complete_subtask(flow_token, inputs)
|
1570
1867
|
|
1571
1868
|
async def _two_factor_enrollment_authentication_app_plain_code_subtask(
|
1572
|
-
self,
|
1573
|
-
|
1869
|
+
self,
|
1870
|
+
flow_token: str,
|
1871
|
+
) -> tuple[str, list[Subtask]]:
|
1574
1872
|
subtask_inputs = [
|
1575
1873
|
{
|
1576
1874
|
"subtask_id": "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask",
|
@@ -1579,12 +1877,12 @@ class Client(BaseHTTPClient):
|
|
1579
1877
|
{
|
1580
1878
|
"subtask_id": "TwoFactorEnrollmentAuthenticationAppEnterCodeSubtask",
|
1581
1879
|
"enter_text": {
|
1582
|
-
"text": self.account.get_totp_code(),
|
1583
1880
|
"link": "next_link",
|
1881
|
+
"text": self.account.get_totp_code(),
|
1584
1882
|
},
|
1585
1883
|
},
|
1586
1884
|
]
|
1587
|
-
return await self.
|
1885
|
+
return await self._complete_subtask(flow_token, subtask_inputs)
|
1588
1886
|
|
1589
1887
|
async def _finish_2fa_task(self, flow_token: str):
|
1590
1888
|
subtask_inputs = [
|
@@ -1593,54 +1891,38 @@ class Client(BaseHTTPClient):
|
|
1593
1891
|
"cta": {"link": "finish_link"},
|
1594
1892
|
}
|
1595
1893
|
]
|
1596
|
-
|
1894
|
+
await self._complete_subtask(flow_token, subtask_inputs)
|
1597
1895
|
|
1598
1896
|
async def _enable_totp(self):
|
1897
|
+
# fmt: off
|
1599
1898
|
flow_token, subtasks = await self._request_2fa_tasks()
|
1600
|
-
flow_token, subtasks = (
|
1601
|
-
|
1602
|
-
)
|
1603
|
-
flow_token, subtasks = (
|
1604
|
-
await self._two_factor_enrollment_authentication_app_begin_subtask(
|
1605
|
-
flow_token
|
1606
|
-
)
|
1899
|
+
flow_token, subtasks = await self._two_factor_enrollment_verify_password_subtask(
|
1900
|
+
flow_token
|
1607
1901
|
)
|
1902
|
+
flow_token, subtasks = (await self._two_factor_enrollment_authentication_app_begin_subtask(flow_token))
|
1608
1903
|
|
1609
1904
|
for subtask in subtasks:
|
1610
|
-
if
|
1611
|
-
subtask["
|
1612
|
-
== "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask"
|
1613
|
-
):
|
1614
|
-
self.account.totp_secret = subtask["show_code"]["code"]
|
1905
|
+
if subtask.id == "TwoFactorEnrollmentAuthenticationAppPlainCodeSubtask":
|
1906
|
+
self.account.totp_secret = subtask.raw_data["show_code"]["code"]
|
1615
1907
|
break
|
1616
1908
|
|
1617
|
-
flow_token, subtasks = (
|
1618
|
-
await self._two_factor_enrollment_authentication_app_plain_code_subtask(
|
1619
|
-
flow_token
|
1620
|
-
)
|
1621
|
-
)
|
1909
|
+
flow_token, subtasks = await self._two_factor_enrollment_authentication_app_plain_code_subtask(flow_token)
|
1622
1910
|
|
1623
1911
|
for subtask in subtasks:
|
1624
|
-
if
|
1625
|
-
subtask["
|
1626
|
-
== "TwoFactorEnrollmentAuthenticationAppCompleteSubtask"
|
1627
|
-
):
|
1628
|
-
result = re.search(
|
1629
|
-
r"\n[a-z0-9]{12}\n", subtask["cta"]["secondary_text"]["text"]
|
1630
|
-
)
|
1912
|
+
if subtask.id == "TwoFactorEnrollmentAuthenticationAppCompleteSubtask":
|
1913
|
+
result = re.search(r"\n[a-z0-9]{12}\n", subtask.raw_data["cta"]["secondary_text"]["text"])
|
1631
1914
|
backup_code = result[0].strip() if result else None
|
1632
1915
|
self.account.backup_code = backup_code
|
1633
1916
|
break
|
1634
1917
|
|
1918
|
+
# fmt: on
|
1635
1919
|
await self._finish_2fa_task(flow_token)
|
1636
1920
|
|
1637
1921
|
async def enable_totp(self):
|
1638
|
-
if not self.account.password:
|
1639
|
-
raise ValueError("Password is required for this action")
|
1640
|
-
|
1641
1922
|
if await self.totp_is_enabled():
|
1642
1923
|
return
|
1643
1924
|
|
1644
|
-
|
1645
|
-
|
1925
|
+
if not self.account.password:
|
1926
|
+
raise ValueError("Password required to enable TOTP")
|
1927
|
+
|
1646
1928
|
await self._enable_totp()
|