tweepy-self 1.9.0__py3-none-any.whl → 1.10.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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()
|