itd-sdk 2.9.0__tar.gz → 2.9.2__tar.gz
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.
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/PKG-INFO +1 -1
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/__init__.py +6 -2
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/notifications.py +3 -2
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/search.py +3 -3
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/auth.py +119 -80
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/__init__.py +2 -2
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/client.py +23 -13
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/config.py +7 -1
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/profile.py +10 -7
- itd_sdk-2.9.2/itd/core/qr.py +169 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/request.py +4 -2
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/utils.py +0 -16
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/enums.py +1 -1
- itd_sdk-2.9.2/itd/models/search.py +38 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/utils.py +6 -5
- itd_sdk-2.9.2/itd/py.typed +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/pyproject.toml +5 -1
- itd_sdk-2.9.0/itd/core/qr.py +0 -112
- itd_sdk-2.9.0/itd/models/__init__.py +0 -4
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/README.md +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/__init__.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/auth.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/comments.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/dwell.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/files.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/pins.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/platform.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/polls.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/portal.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/posts.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/reports.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/sessions.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/subscription.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/users.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/api/verification.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/__init__.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/base.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/base.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/cloudflare.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/captcha/itd.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/cli.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/default.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/dwell.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/limiter.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/logger.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/timer.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/core/visibility.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/exceptions.py +0 -0
- /itd_sdk-2.9.0/itd/py.typed → /itd_sdk-2.9.2/itd/models/__init__.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/announcement.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/clan.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/comment.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/file.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/hashtag.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/notification.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/pin.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/poll.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/portal.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/post.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/report.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/session.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/span.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/user.py +0 -0
- {itd_sdk-2.9.0 → itd_sdk-2.9.2}/itd/models/version.py +0 -0
|
@@ -37,7 +37,7 @@ from time import sleep
|
|
|
37
37
|
__version__ = version('itd-sdk')
|
|
38
38
|
|
|
39
39
|
from itd.core.client import Client as ITDClient
|
|
40
|
-
from itd.core.client import init_client
|
|
40
|
+
from itd.core.client import init_client, init_not_authed_client
|
|
41
41
|
from itd.core.config import Config as ITDConfig
|
|
42
42
|
from itd.core.default import LimiterConfig, limiters
|
|
43
43
|
from itd.core.default import set_config as set_limiter_config
|
|
@@ -51,8 +51,9 @@ from itd.models.notification import Notification, Notifications, Ntf, Ntfs
|
|
|
51
51
|
from itd.models.poll import NewPoll
|
|
52
52
|
from itd.models.portal import Portal
|
|
53
53
|
from itd.models.post import HashtagPosts, LikedPosts, Post, Posts, UserPosts
|
|
54
|
+
from itd.models.search import Search
|
|
54
55
|
from itd.models.session import Sessions
|
|
55
|
-
from itd.models.user import Me, User, Users, get_follow_status
|
|
56
|
+
from itd.models.user import Me, User, Users, WhoToFollow, get_follow_status
|
|
56
57
|
from itd.models.version import Apps, Changelog
|
|
57
58
|
|
|
58
59
|
|
|
@@ -71,6 +72,7 @@ __all__ = [
|
|
|
71
72
|
'ITDClient',
|
|
72
73
|
'ITDConfig',
|
|
73
74
|
'init_client',
|
|
75
|
+
'init_not_authed_client',
|
|
74
76
|
'Comment',
|
|
75
77
|
'Clan',
|
|
76
78
|
'TopClans',
|
|
@@ -94,6 +96,8 @@ __all__ = [
|
|
|
94
96
|
'Me',
|
|
95
97
|
'Users',
|
|
96
98
|
'Portal',
|
|
99
|
+
'WhoToFollow',
|
|
100
|
+
'Search',
|
|
97
101
|
'get_follow_status',
|
|
98
102
|
'set_limiter_config',
|
|
99
103
|
'LimiterConfig',
|
|
@@ -15,8 +15,9 @@ def get_notifications(client: Client, limit: int = 20, offset: int = 0):
|
|
|
15
15
|
return {'limit': limit, 'offset': offset}
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
@endpoint('post', 'notifications/
|
|
19
|
-
def mark_as_read(client: Client, id: UUID):
|
|
18
|
+
@endpoint('post', 'notifications/read-batch', NotFoundError('Notification', json_check=lambda json: json.get('success') is False))
|
|
19
|
+
def mark_as_read(client: Client, id: UUID):
|
|
20
|
+
return {'ids': [str(id)]}
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
@endpoint('post', 'notifications/read-all')
|
|
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
+
from itd.core.request import endpoint
|
|
5
6
|
from itd.enums import AuthLevel
|
|
6
7
|
from itd.exceptions import ValidationError
|
|
7
|
-
from itd.core.request import endpoint
|
|
8
8
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from itd.core.client import Client
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@endpoint('get', 'search', ValidationError(), level=AuthLevel.NO)
|
|
14
|
-
def search(client: Client, query: str,
|
|
15
|
-
return {'userLimit':
|
|
14
|
+
def search(client: Client, query: str, users_limit: int = 10, hashtags_limit: int = 10):
|
|
15
|
+
return {'userLimit': users_limit, 'hashtagLimit': hashtags_limit, 'q': query}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from dataclasses import dataclass
|
|
1
3
|
from os import getenv
|
|
2
4
|
from typing import TYPE_CHECKING
|
|
3
5
|
|
|
4
6
|
from itd.api.auth import sign_in
|
|
5
7
|
from itd.core.captcha import get_turnstile
|
|
6
8
|
from itd.core.logger import RICH_AVAILABLE, get_logger, iprint, rich_input
|
|
7
|
-
from itd.core.qr import
|
|
9
|
+
from itd.core.qr import interactive_auth_qr
|
|
8
10
|
from itd.core.utils import shorten_token
|
|
9
11
|
from itd.exceptions import (
|
|
10
12
|
AccessTokenExpiredError,
|
|
@@ -28,17 +30,25 @@ if RICH_AVAILABLE:
|
|
|
28
30
|
l = get_logger('auth')
|
|
29
31
|
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
@contextmanager
|
|
34
|
+
def _status(text: str):
|
|
35
|
+
if not RICH_AVAILABLE:
|
|
36
|
+
iprint(l, text)
|
|
37
|
+
yield None
|
|
38
|
+
return
|
|
39
|
+
with Status(text) as status:
|
|
40
|
+
yield status
|
|
36
41
|
|
|
37
|
-
|
|
42
|
+
|
|
43
|
+
def _auth_login(client: 'Client', email: str, password: str) -> bool:
|
|
44
|
+
with _status('Solving captcha..') as status:
|
|
45
|
+
turnstile = get_turnstile(client, status=status)
|
|
46
|
+
if status:
|
|
47
|
+
status.update('Verifying..')
|
|
38
48
|
try:
|
|
39
|
-
|
|
49
|
+
apply_auth(client, CredentialsAuth(email, password, turnstile))
|
|
40
50
|
except CaptchaFailedError:
|
|
41
|
-
l.error('captcha verify failed
|
|
51
|
+
l.error('captcha verify failed; please fill issue at https://github.com/itd-sdk/itd-sdk/issues/new')
|
|
42
52
|
except InvalidCredentialsError:
|
|
43
53
|
l.error('invalid email or password')
|
|
44
54
|
except EmailDomainNotAllowedError:
|
|
@@ -46,34 +56,15 @@ def _auth_login(client: 'Client', email: str, password: str):
|
|
|
46
56
|
except InvalidEmailError:
|
|
47
57
|
l.error('invalid email format')
|
|
48
58
|
else:
|
|
49
|
-
client._profile.set_refresh(res.cookies['refresh_token'], set_expire=True)
|
|
50
|
-
client._profile.set_access(res.json()['accessToken'])
|
|
51
|
-
client._profile.creds_valid = True
|
|
52
|
-
return True
|
|
53
|
-
|
|
54
|
-
if RICH_AVAILABLE:
|
|
55
|
-
with Status('Solving captcha..') as status:
|
|
56
|
-
turnstile = get_turnstile(client, status=status)
|
|
57
|
-
status.update('Verifying..')
|
|
58
|
-
if _sign_in(turnstile):
|
|
59
|
-
iprint(l, 'accepted')
|
|
60
|
-
return True
|
|
61
|
-
else:
|
|
62
|
-
iprint(l, 'solving captcha..')
|
|
63
|
-
if _sign_in(get_turnstile(client)):
|
|
64
59
|
iprint(l, 'accepted')
|
|
65
60
|
return True
|
|
61
|
+
return False
|
|
66
62
|
|
|
67
|
-
client._profile.creds_valid = False
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _auth_refresh(client: 'Client', refresh: str):
|
|
71
|
-
client._profile.set_refresh(refresh, set_expire=True)
|
|
72
|
-
client._set_from_profile()
|
|
73
63
|
|
|
74
|
-
|
|
64
|
+
def _auth_refresh(client: 'Client', refresh: str) -> bool:
|
|
65
|
+
with _status('Verifying..'):
|
|
75
66
|
try:
|
|
76
|
-
client
|
|
67
|
+
apply_auth(client, RefreshAuth(refresh))
|
|
77
68
|
except SessionExpiredError:
|
|
78
69
|
l.error('refresh token expired')
|
|
79
70
|
except SessionNotFoundError:
|
|
@@ -81,28 +72,15 @@ def _auth_refresh(client: 'Client', refresh: str):
|
|
|
81
72
|
except SessionRevokedError:
|
|
82
73
|
l.error('refresh token revoked')
|
|
83
74
|
else:
|
|
84
|
-
return True
|
|
85
|
-
|
|
86
|
-
if RICH_AVAILABLE:
|
|
87
|
-
with Status('Verifying..'):
|
|
88
|
-
if _verify():
|
|
89
|
-
iprint(l, 'accepted')
|
|
90
|
-
return True
|
|
91
|
-
else:
|
|
92
|
-
if _verify():
|
|
93
75
|
iprint(l, 'accepted')
|
|
94
76
|
return True
|
|
77
|
+
return False
|
|
95
78
|
|
|
96
|
-
client._profile.refresh_valid = False
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _auth_access(client: 'Client', access: str):
|
|
100
|
-
client._profile.set_access(access)
|
|
101
|
-
client._set_from_profile()
|
|
102
79
|
|
|
103
|
-
|
|
80
|
+
def _auth_access(client: 'Client', access: str) -> bool:
|
|
81
|
+
with _status('Verifying..'):
|
|
104
82
|
try:
|
|
105
|
-
client
|
|
83
|
+
apply_auth(client, AccessAuth(access))
|
|
106
84
|
except AccessTokenExpiredError:
|
|
107
85
|
l.error('access token expired')
|
|
108
86
|
except InvalidAccessTokenError:
|
|
@@ -110,19 +88,95 @@ def _auth_access(client: 'Client', access: str):
|
|
|
110
88
|
except JWTAlgorithmUnsupportedError:
|
|
111
89
|
l.error('jwt algorithm unsupported')
|
|
112
90
|
else:
|
|
113
|
-
return True
|
|
114
|
-
|
|
115
|
-
if RICH_AVAILABLE:
|
|
116
|
-
with Status('Verifying..'):
|
|
117
|
-
if _verify():
|
|
118
|
-
iprint(l, 'accepted')
|
|
119
|
-
return True
|
|
120
|
-
else:
|
|
121
|
-
if _verify():
|
|
122
91
|
iprint(l, 'accepted')
|
|
123
92
|
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class CredentialsAuth:
|
|
98
|
+
email: str
|
|
99
|
+
password: str
|
|
100
|
+
turnstile: str | None = None
|
|
124
101
|
|
|
125
|
-
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class RefreshAuth:
|
|
105
|
+
refresh: str
|
|
106
|
+
access: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class AccessAuth:
|
|
111
|
+
access: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
AuthMethod = CredentialsAuth | RefreshAuth | AccessAuth
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def apply_auth(client: 'Client', auth: AuthMethod | None) -> None:
|
|
118
|
+
match auth:
|
|
119
|
+
case CredentialsAuth():
|
|
120
|
+
client._profile.email = auth.email
|
|
121
|
+
client._profile.password = auth.password
|
|
122
|
+
client._profile.creds_valid = True
|
|
123
|
+
client._set_from_profile()
|
|
124
|
+
try:
|
|
125
|
+
res = sign_in(client, auth.email, auth.password, 'turnstileToken', auth.turnstile or get_turnstile(client)[1])
|
|
126
|
+
except Exception:
|
|
127
|
+
client._profile.email = None
|
|
128
|
+
client._profile.password = None
|
|
129
|
+
client._profile.creds_valid = False
|
|
130
|
+
client._set_from_profile()
|
|
131
|
+
raise
|
|
132
|
+
client._profile.set_refresh(res.cookies['refresh_token'], set_expire=True)
|
|
133
|
+
client._profile.set_access(res.json()['accessToken'])
|
|
134
|
+
|
|
135
|
+
case RefreshAuth():
|
|
136
|
+
client._profile.set_refresh(auth.refresh, set_expire=True)
|
|
137
|
+
if auth.access:
|
|
138
|
+
client._profile.set_access(auth.access)
|
|
139
|
+
client._set_from_profile()
|
|
140
|
+
try:
|
|
141
|
+
client.refresh_auth()
|
|
142
|
+
except Exception:
|
|
143
|
+
client._profile.refresh_valid = False
|
|
144
|
+
client._set_from_profile()
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
case AccessAuth():
|
|
148
|
+
client._profile.set_access(auth.access)
|
|
149
|
+
client._set_from_profile()
|
|
150
|
+
try:
|
|
151
|
+
client.user.refresh()
|
|
152
|
+
except Exception:
|
|
153
|
+
client._profile.access_valid = False
|
|
154
|
+
client._set_from_profile()
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
client._profile.flush()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def env_auth(client: 'Client') -> bool:
|
|
161
|
+
method = getenv('ITD_AUTH_METHOD')
|
|
162
|
+
if not method:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
match method:
|
|
166
|
+
case 'login':
|
|
167
|
+
auth = CredentialsAuth(getenv('ITD_LOGIN', ''), getenv('ITD_PASSWORD', ''))
|
|
168
|
+
case 'refresh':
|
|
169
|
+
auth = RefreshAuth(getenv('ITD_REFRESH', ''))
|
|
170
|
+
case 'access':
|
|
171
|
+
auth = AccessAuth(getenv('ITD_ACCESS', ''))
|
|
172
|
+
case 'no':
|
|
173
|
+
auth = None
|
|
174
|
+
case _:
|
|
175
|
+
raise ValueError(f'unknown env auth method {method}')
|
|
176
|
+
|
|
177
|
+
apply_auth(client, auth)
|
|
178
|
+
l.info('authorized env method=%s', method)
|
|
179
|
+
return True
|
|
126
180
|
|
|
127
181
|
|
|
128
182
|
def interactive_auth(client: 'Client') -> bool:
|
|
@@ -141,24 +195,6 @@ def interactive_auth(client: 'Client') -> bool:
|
|
|
141
195
|
return True
|
|
142
196
|
client._credtest = True
|
|
143
197
|
|
|
144
|
-
if getenv('ITD_AUTH_METHOD'):
|
|
145
|
-
match getenv('ITD_AUTH_METHOD'):
|
|
146
|
-
case 'login':
|
|
147
|
-
if _auth_login(client, getenv('ITD_LOGIN', ''), getenv('ITD_PASSWORD', '')):
|
|
148
|
-
return True
|
|
149
|
-
case 'refresh':
|
|
150
|
-
if _auth_refresh(client, getenv('ITD_REFRESH', '')):
|
|
151
|
-
return True
|
|
152
|
-
case 'access':
|
|
153
|
-
if _auth_access(client, getenv('ITD_ACCESS', '')):
|
|
154
|
-
return True
|
|
155
|
-
case _:
|
|
156
|
-
l.error('unknown option')
|
|
157
|
-
quit()
|
|
158
|
-
|
|
159
|
-
l.error('auth failed')
|
|
160
|
-
quit()
|
|
161
|
-
|
|
162
198
|
if not client._profile.refresh_valid and client._profile.refresh:
|
|
163
199
|
l.warning('session file refresh token is not valid')
|
|
164
200
|
|
|
@@ -173,7 +209,8 @@ def interactive_auth(client: 'Client') -> bool:
|
|
|
173
209
|
iprint(l, '[2] Login via QR code')
|
|
174
210
|
iprint(l, '[3] Manually auth using refresh token')
|
|
175
211
|
iprint(l, '[4] Manually auth using access token')
|
|
176
|
-
iprint(l, '[5]
|
|
212
|
+
iprint(l, '[5] Init client with authorization')
|
|
213
|
+
iprint(l, '[6] Quit')
|
|
177
214
|
|
|
178
215
|
while True:
|
|
179
216
|
option = rich_input('option', 'magenta')
|
|
@@ -183,7 +220,7 @@ def interactive_auth(client: 'Client') -> bool:
|
|
|
183
220
|
if _auth_login(client, rich_input('email', 'green'), rich_input('password', 'green', password=True)):
|
|
184
221
|
return True
|
|
185
222
|
case '2':
|
|
186
|
-
if
|
|
223
|
+
if interactive_auth_qr(client):
|
|
187
224
|
return True
|
|
188
225
|
case '3':
|
|
189
226
|
if _auth_refresh(client, rich_input('refresh token', 'cyan', password=True)):
|
|
@@ -192,7 +229,9 @@ def interactive_auth(client: 'Client') -> bool:
|
|
|
192
229
|
l.info('note: authorization will work only for ~15min')
|
|
193
230
|
if _auth_access(client, rich_input('access token', 'cyan')):
|
|
194
231
|
return True
|
|
195
|
-
case '5'
|
|
232
|
+
case '5':
|
|
233
|
+
return True
|
|
234
|
+
case '6' | 'q' | 'quit':
|
|
196
235
|
quit()
|
|
197
236
|
case _:
|
|
198
237
|
l.error('unknown option')
|
|
@@ -19,7 +19,7 @@ def get_turnstile(client: 'Client | None' = None, status=None) -> str: # tuple[
|
|
|
19
19
|
# raise RuntimeError(f'Unknown provider: {provider_data["provider"]}')
|
|
20
20
|
|
|
21
21
|
# provider = providers[provider_data['provider']]()
|
|
22
|
-
if client and client.config.captcha_solve:
|
|
22
|
+
if client and not client.config.captcha_solve:
|
|
23
23
|
l.warning('captcha solving disabled')
|
|
24
24
|
iprint(l, 'solve captcha on other devices via `uv run itd captcha`, then paste result here')
|
|
25
25
|
iprint(l, 'enter `continue` to force solve captcha on this device')
|
|
@@ -32,7 +32,7 @@ def get_turnstile(client: 'Client | None' = None, status=None) -> str: # tuple[
|
|
|
32
32
|
status.start()
|
|
33
33
|
|
|
34
34
|
if not CAPTCHA_AVAILABLE:
|
|
35
|
-
l.error('captcha libraries not installed; install via `uv add itd-sdk[captcha]`')
|
|
35
|
+
l.error(r'captcha libraries not installed; install via `uv add itd-sdk\[captcha]`')
|
|
36
36
|
provider = CloudflareProvider(False if not client else client.config.captcha_headless)
|
|
37
37
|
provider.launch()
|
|
38
38
|
turnstile = provider.solve()
|
|
@@ -12,7 +12,7 @@ from requests import Response, Session
|
|
|
12
12
|
from requests.adapters import HTTPAdapter
|
|
13
13
|
|
|
14
14
|
from itd.api.auth import change_password, logout, refresh_token, sign_in
|
|
15
|
-
from itd.core.auth import interactive_auth
|
|
15
|
+
from itd.core.auth import AuthMethod, apply_auth, env_auth, interactive_auth
|
|
16
16
|
from itd.core.captcha import get_turnstile
|
|
17
17
|
from itd.core.config import Config
|
|
18
18
|
from itd.core.default import maybe_get_default_client, set_default_client
|
|
@@ -20,7 +20,6 @@ from itd.core.dwell import DwellTracker
|
|
|
20
20
|
from itd.core.logger import get_logger
|
|
21
21
|
from itd.core.profile import Profile, clear_anon_profile
|
|
22
22
|
from itd.core.request import fetch
|
|
23
|
-
from itd.core.utils import get_profile
|
|
24
23
|
from itd.core.visibility import VisibilityTracker
|
|
25
24
|
from itd.enums import AuthLevel
|
|
26
25
|
from itd.exceptions import AccessTokenExpiredError, InsufficientAuthLevelError, SessionExpiredError
|
|
@@ -33,26 +32,31 @@ l = get_logger('client')
|
|
|
33
32
|
|
|
34
33
|
|
|
35
34
|
class Client:
|
|
36
|
-
def __init__(self, name: str, config: Config | None = None):
|
|
35
|
+
def __init__(self, name: str, config: Config | None = None, auth: AuthMethod | None = None):
|
|
37
36
|
l.info('init client %s', name)
|
|
38
37
|
self.config = config or Config()
|
|
39
38
|
|
|
40
|
-
self.auth_level: AuthLevel = AuthLevel.NO
|
|
41
|
-
self._profile: Profile = get_profile(name)
|
|
42
|
-
|
|
43
|
-
self._refresh_lock = RLock() # so background timers and main thread dont refresh token simultaneously # еба он мой стиль коментов спиздил
|
|
44
|
-
|
|
45
39
|
self.session = Session()
|
|
46
40
|
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
|
|
47
41
|
self.session.mount('https://', adapter)
|
|
48
42
|
|
|
43
|
+
self.auth_level: AuthLevel = AuthLevel.NO
|
|
44
|
+
self._profile: Profile = Profile.get(name)
|
|
45
|
+
self._set_from_profile()
|
|
46
|
+
|
|
47
|
+
self._refresh_lock = RLock() # so background timers and main thread dont refresh token simultaneously # еба он мой стиль коментов спиздил
|
|
48
|
+
|
|
49
49
|
if maybe_get_default_client() is None or self.config.is_default:
|
|
50
50
|
set_default_client(self)
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
self._credtest = True
|
|
53
|
+
if auth is not None:
|
|
54
|
+
apply_auth(self, auth)
|
|
55
|
+
if name != 'no-auth' and self.auth_level == AuthLevel.NO:
|
|
56
|
+
if not env_auth(self) and self.config._interactive_auth:
|
|
57
|
+
interactive_auth(self)
|
|
54
58
|
self._profile.flush()
|
|
55
|
-
self._credtest = False
|
|
59
|
+
self._credtest = False
|
|
56
60
|
|
|
57
61
|
self.dwell_tracker = DwellTracker(self)
|
|
58
62
|
self.dwell_tracker.start()
|
|
@@ -132,6 +136,7 @@ class Client:
|
|
|
132
136
|
|
|
133
137
|
if level >= AuthLevel.REFRESH and ((self._profile.refresh and self._profile.is_refresh_expired) or not self._profile.refresh_valid):
|
|
134
138
|
self._profile.refresh_valid = False
|
|
139
|
+
self._profile.flush()
|
|
135
140
|
if self.auth_level == AuthLevel.LOGIN:
|
|
136
141
|
self.login()
|
|
137
142
|
else:
|
|
@@ -145,6 +150,7 @@ class Client:
|
|
|
145
150
|
and ((self._profile.access_data and self._profile.access_data.is_expired) or not self._profile.access_valid)
|
|
146
151
|
):
|
|
147
152
|
self._profile.access_valid = False
|
|
153
|
+
self._profile.flush()
|
|
148
154
|
if self.auth_level >= AuthLevel.REFRESH:
|
|
149
155
|
self.refresh_auth()
|
|
150
156
|
else:
|
|
@@ -209,8 +215,12 @@ class Client:
|
|
|
209
215
|
change_password(self, old, new)
|
|
210
216
|
|
|
211
217
|
|
|
212
|
-
def init_client(name: str | None = None, config: Config =
|
|
218
|
+
def init_client(name: str | None = None, config: Config | None = None, auth: AuthMethod | None = None) -> Client:
|
|
213
219
|
if name == 'anon':
|
|
214
220
|
clear_anon_profile()
|
|
215
221
|
register(clear_anon_profile)
|
|
216
|
-
return Client(name or 'default', config=config)
|
|
222
|
+
return Client(name or 'default', config=config, auth=auth)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def init_not_authed_client(config: Config | None = None):
|
|
226
|
+
return Client('no-auth', config=config)
|
|
@@ -114,7 +114,8 @@ class Config:
|
|
|
114
114
|
on_exceptions: dict[type[Exception], Callable[[Exception], None]] = field(default_factory=dict)
|
|
115
115
|
batch_sizes: BatchSizes = field(default_factory=BatchSizes)
|
|
116
116
|
refresh_token_cookie_name: str = 'refresh_token'
|
|
117
|
-
|
|
117
|
+
|
|
118
|
+
interactive_auth: bool | None = None
|
|
118
119
|
|
|
119
120
|
def __post_init__(self):
|
|
120
121
|
match self.user_agent:
|
|
@@ -162,3 +163,8 @@ class Config:
|
|
|
162
163
|
self._retry_exceptions = ()
|
|
163
164
|
else:
|
|
164
165
|
self._retry_exceptions = self.retry_exceptions
|
|
166
|
+
|
|
167
|
+
if self.interactive_auth is None:
|
|
168
|
+
self._interactive_auth = self.client_type != 'client'
|
|
169
|
+
else:
|
|
170
|
+
self._interactive_auth = self.interactive_auth
|
|
@@ -51,16 +51,18 @@ class Profile(BaseModel):
|
|
|
51
51
|
def flush(self):
|
|
52
52
|
assert self._file
|
|
53
53
|
self.updated_at = datetime.now()
|
|
54
|
+
if self._file.name == 'no-auth':
|
|
55
|
+
return
|
|
54
56
|
self._file.write_text(dumps(self.model_dump(mode='json')))
|
|
55
57
|
|
|
56
58
|
def update(self):
|
|
57
59
|
assert self._file
|
|
60
|
+
if self._file.name == 'no-auth':
|
|
61
|
+
return False
|
|
58
62
|
new = self.model_validate(loads(self._file.read_text()))
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return True
|
|
63
|
-
return False
|
|
63
|
+
for field in Profile.model_fields:
|
|
64
|
+
setattr(self, field, getattr(new, field))
|
|
65
|
+
return True
|
|
64
66
|
|
|
65
67
|
@property
|
|
66
68
|
def is_refresh_expired(self) -> bool:
|
|
@@ -88,9 +90,10 @@ class Profile(BaseModel):
|
|
|
88
90
|
@classmethod
|
|
89
91
|
def get(cls, name: str):
|
|
90
92
|
file = user_data_path('itd_sdk', False, ensure_exists=True) / f'{name}.json'
|
|
91
|
-
instance = cls(
|
|
93
|
+
instance = cls()
|
|
94
|
+
instance._file = file
|
|
92
95
|
if not file.exists():
|
|
93
|
-
instance
|
|
96
|
+
instance.flush()
|
|
94
97
|
else:
|
|
95
98
|
instance.update()
|
|
96
99
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from json import loads
|
|
2
|
+
from time import sleep
|
|
3
|
+
from typing import TYPE_CHECKING, Iterator
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
from sseclient import SSEClient
|
|
8
|
+
|
|
9
|
+
from itd.api.auth import qr_claim, qr_start, qr_stream
|
|
10
|
+
from itd.core.logger import RICH_AVAILABLE, get_logger, iprint
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from itd.core.client import Client
|
|
14
|
+
from itd.core.config import Config
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from qrcode import QRCode
|
|
18
|
+
|
|
19
|
+
QR_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
QR_AVAILABLE = False
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from rich.status import Status
|
|
25
|
+
except ImportError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
l = get_logger('qr')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ITDQRCode(BaseModel):
|
|
32
|
+
id: UUID = Field(alias='qrId')
|
|
33
|
+
payload: str
|
|
34
|
+
claim_token: str = Field(alias='claimToken')
|
|
35
|
+
expires_in: int = Field(90, alias='expiresIn')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QRLogin:
|
|
39
|
+
def __init__(self):
|
|
40
|
+
from itd import init_not_authed_client
|
|
41
|
+
|
|
42
|
+
self.client = init_not_authed_client()
|
|
43
|
+
self.stream = None
|
|
44
|
+
self.qr = None
|
|
45
|
+
self.auth = None
|
|
46
|
+
self.status = 'pending'
|
|
47
|
+
self.refresh()
|
|
48
|
+
|
|
49
|
+
def refresh(self) -> ITDQRCode:
|
|
50
|
+
self.close()
|
|
51
|
+
self.qr = ITDQRCode.model_validate(qr_start(self.client).json())
|
|
52
|
+
l.debug('qr code: id=%s claim_token=%s', self.qr.id, self.qr.claim_token)
|
|
53
|
+
return self.qr
|
|
54
|
+
|
|
55
|
+
def events(self) -> Iterator[str]:
|
|
56
|
+
assert self.qr
|
|
57
|
+
self.stream = qr_stream(self.client, qr_id=self.qr.id, claim_token=self.qr.claim_token)
|
|
58
|
+
l.debug('start stream')
|
|
59
|
+
try:
|
|
60
|
+
for event in SSEClient(self.stream).events():
|
|
61
|
+
self.status = loads(event.data)['status']
|
|
62
|
+
l.debug('qr code status: %s', self.status)
|
|
63
|
+
yield self.status
|
|
64
|
+
if self.status in ('approved', 'rejected'):
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if self.status == 'approved':
|
|
68
|
+
self.claim()
|
|
69
|
+
self.status = 'authorized'
|
|
70
|
+
yield 'authorized'
|
|
71
|
+
elif self.status != 'rejected':
|
|
72
|
+
l.debug('expire qr')
|
|
73
|
+
self.status = 'expired'
|
|
74
|
+
yield 'expired'
|
|
75
|
+
|
|
76
|
+
finally:
|
|
77
|
+
self.close()
|
|
78
|
+
l.debug('stop stream')
|
|
79
|
+
|
|
80
|
+
def claim(self):
|
|
81
|
+
from itd.core.auth import RefreshAuth
|
|
82
|
+
|
|
83
|
+
assert self.qr
|
|
84
|
+
res = qr_claim(self.client, qr_id=self.qr.id, claim_token=self.qr.claim_token)
|
|
85
|
+
self.auth = RefreshAuth(res.cookies['refresh_token'], res.json()['accessToken'])
|
|
86
|
+
|
|
87
|
+
def get_client(self, name: str | None = None, config: 'Config | None' = None):
|
|
88
|
+
from itd import ITDConfig, init_client
|
|
89
|
+
|
|
90
|
+
if config is None:
|
|
91
|
+
config = ITDConfig()
|
|
92
|
+
config.is_default = True
|
|
93
|
+
|
|
94
|
+
assert self.auth
|
|
95
|
+
return init_client(name, config, auth=self.auth)
|
|
96
|
+
|
|
97
|
+
def close(self):
|
|
98
|
+
if self.stream:
|
|
99
|
+
self.stream.close()
|
|
100
|
+
self.stream = None
|
|
101
|
+
|
|
102
|
+
def __enter__(self) -> 'QRLogin':
|
|
103
|
+
return self
|
|
104
|
+
|
|
105
|
+
def __exit__(self, *exc) -> None:
|
|
106
|
+
self.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def interactive_auth_qr(client: 'Client'):
|
|
110
|
+
if not QR_AVAILABLE:
|
|
111
|
+
l.error(r'qrcode library not installed; install via `uv add itd-sdk\[qrcode]`')
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
if RICH_AVAILABLE:
|
|
115
|
+
status = Status('Waiting for scan')
|
|
116
|
+
status.start()
|
|
117
|
+
else:
|
|
118
|
+
status = None
|
|
119
|
+
|
|
120
|
+
with QRLogin() as qr:
|
|
121
|
+
try:
|
|
122
|
+
for _ in range(5):
|
|
123
|
+
qr.refresh()
|
|
124
|
+
if status:
|
|
125
|
+
status.update('Generating QR')
|
|
126
|
+
assert qr.qr
|
|
127
|
+
ascii_qr = QRCode(border=2)
|
|
128
|
+
ascii_qr.add_data(qr.qr.payload)
|
|
129
|
+
iprint(l, 'scan QR code with mobile app:')
|
|
130
|
+
ascii_qr.print_ascii(invert=True)
|
|
131
|
+
if status:
|
|
132
|
+
status.update('Waiting for scan')
|
|
133
|
+
|
|
134
|
+
for event in qr.events():
|
|
135
|
+
if event == 'approved':
|
|
136
|
+
iprint(l, 'qr code approved')
|
|
137
|
+
assert qr.auth
|
|
138
|
+
assert qr.auth.access
|
|
139
|
+
client._profile.set_access(qr.auth.access)
|
|
140
|
+
client._profile.set_refresh(qr.auth.refresh, set_expire=True)
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
elif event == 'rejected':
|
|
144
|
+
l.error('qr code rejected')
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
elif event == 'scanned':
|
|
148
|
+
iprint(l, 'qr code scanned')
|
|
149
|
+
if status:
|
|
150
|
+
status.update('Waiting for accept')
|
|
151
|
+
|
|
152
|
+
elif event == 'pending':
|
|
153
|
+
iprint(l, 'qr code pending')
|
|
154
|
+
|
|
155
|
+
# elif res['status'] == 'captcha_required':
|
|
156
|
+
# iprint(l, 'captcha required')
|
|
157
|
+
# if status:
|
|
158
|
+
# status.update('Solving captcha')
|
|
159
|
+
# turnstile = get_turnstile(client)
|
|
160
|
+
|
|
161
|
+
sleep(5)
|
|
162
|
+
|
|
163
|
+
l.error('all retries to auth qr exceeded')
|
|
164
|
+
return False
|
|
165
|
+
finally:
|
|
166
|
+
if status:
|
|
167
|
+
status.stop()
|
|
168
|
+
if qr.stream:
|
|
169
|
+
qr.stream.close()
|
|
@@ -241,6 +241,7 @@ def api_wrapper(*exceptions: ITDException):
|
|
|
241
241
|
# token is checked before the request, but server still can reject it (clock skew, revoked session) - refresh and repeat the request once, before callbacks
|
|
242
242
|
if isinstance(exception, (AccessTokenExpiredError, InvalidAccessTokenError)) and not access_reauthed and not client._credtest:
|
|
243
243
|
client._profile.access_valid = False
|
|
244
|
+
client._profile.flush()
|
|
244
245
|
if name != 'refresh_token':
|
|
245
246
|
access_reauthed = True
|
|
246
247
|
l.warning('%s on %s: refresh access_token and retry', exception.__class__.__name__, name)
|
|
@@ -252,9 +253,10 @@ def api_wrapper(*exceptions: ITDException):
|
|
|
252
253
|
and not refresh_reauthed
|
|
253
254
|
and not client._credtest
|
|
254
255
|
):
|
|
255
|
-
|
|
256
|
+
client._profile.refresh_valid = False
|
|
257
|
+
client._profile.flush()
|
|
256
258
|
if name != 'sign_in':
|
|
257
|
-
|
|
259
|
+
refresh_reauthed = True
|
|
258
260
|
l.warning('%s on %s: refresh refresh_token and retry', exception.__class__.__name__, name)
|
|
259
261
|
client.login()
|
|
260
262
|
return exec()
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
|
-
from json import dumps, loads
|
|
3
2
|
from sys import version
|
|
4
3
|
from uuid import UUID
|
|
5
4
|
|
|
6
|
-
from platformdirs import user_data_path
|
|
7
|
-
|
|
8
|
-
from itd.core.profile import Profile
|
|
9
|
-
|
|
10
5
|
|
|
11
6
|
def get_sdk_user_agent():
|
|
12
7
|
from itd import __version__ # i fucking hate circular imports this is sooo stupid
|
|
@@ -14,17 +9,6 @@ def get_sdk_user_agent():
|
|
|
14
9
|
return f'itd-sdk/{__version__} (Python/{version})'
|
|
15
10
|
|
|
16
11
|
|
|
17
|
-
def get_profile(name: str) -> Profile:
|
|
18
|
-
file = user_data_path('itd_sdk', False, ensure_exists=True) / f'{name}.json'
|
|
19
|
-
if not file.exists():
|
|
20
|
-
model = Profile(_file=file)
|
|
21
|
-
file.write_text(dumps(model.model_dump(mode='json')))
|
|
22
|
-
else:
|
|
23
|
-
model = Profile.model_validate(loads(file.read_text()))
|
|
24
|
-
model._file = file
|
|
25
|
-
return model
|
|
26
|
-
|
|
27
|
-
|
|
28
12
|
def shorten_token(value: str | None, max_len: int = 20):
|
|
29
13
|
if value is None:
|
|
30
14
|
return None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
from itd.api.search import search
|
|
4
|
+
from itd.core.base import ITDBaseModel
|
|
5
|
+
from itd.models.hashtag import Hashtag
|
|
6
|
+
from itd.models.user import User
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from itd.core.client import Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Search(ITDBaseModel):
|
|
13
|
+
_refreshable = False
|
|
14
|
+
users: list[User]
|
|
15
|
+
hashtags: list[Hashtag]
|
|
16
|
+
|
|
17
|
+
def __init__(self, query: str, users_limit: int = 20, hashtags_limit: int = 20, *, client: 'Client | None' = None):
|
|
18
|
+
super().__init__(client=client)
|
|
19
|
+
self.query = query
|
|
20
|
+
self.users_limit = users_limit
|
|
21
|
+
self.hashtags_limit = hashtags_limit
|
|
22
|
+
self.refresh()
|
|
23
|
+
|
|
24
|
+
def refresh(self, client: 'Client | None' = None):
|
|
25
|
+
client = client or self.client
|
|
26
|
+
res = search(client, self.query, self.users_limit, self.hashtags_limit).json()['data']
|
|
27
|
+
self.users = [User.from_dict(user, client=client) for user in res['users']]
|
|
28
|
+
self.hashtags = [Hashtag.from_dict(hashtags, client=client) for hashtags in res['hashtags']]
|
|
29
|
+
|
|
30
|
+
def __iter__(self):
|
|
31
|
+
yield from self.users
|
|
32
|
+
yield from self.hashtags
|
|
33
|
+
|
|
34
|
+
def __len__(self) -> int:
|
|
35
|
+
return len(self.users) + len(self.hashtags)
|
|
36
|
+
|
|
37
|
+
def __bool__(self) -> bool:
|
|
38
|
+
return bool(self.users or self.hashtags)
|
|
@@ -58,10 +58,11 @@ def parse_html(text: str) -> tuple[str, list[Span]]:
|
|
|
58
58
|
"""Спарсить HTML
|
|
59
59
|
|
|
60
60
|
Поддерживаемые теги:
|
|
61
|
-
-
|
|
61
|
+
- `<b>, <i>, <s>, <u>, <code>, <spoiler>, <q>`
|
|
62
|
+
- `<a href="url">text</a>` или `<а>url</а>` (ссылки)
|
|
62
63
|
|
|
63
64
|
Args:
|
|
64
|
-
text: HTML-строка для парсинга
|
|
65
|
+
text (str): HTML-строка для парсинга
|
|
65
66
|
|
|
66
67
|
Returns:
|
|
67
68
|
str: чистая строка
|
|
@@ -84,18 +85,18 @@ def parse_html(text: str) -> tuple[str, list[Span]]:
|
|
|
84
85
|
return root.text_content(), spans
|
|
85
86
|
|
|
86
87
|
|
|
87
|
-
def parse_md(
|
|
88
|
+
def parse_md(text: str) -> tuple[str, list[Span]]:
|
|
88
89
|
"""Спарсить markdown
|
|
89
90
|
|
|
90
91
|
Поддерживаемые теги:
|
|
91
92
|
- *, _, **, __, ~, ~~, `, ||, \\[text](url)
|
|
92
93
|
|
|
93
94
|
Args:
|
|
94
|
-
text: строка для парсинга
|
|
95
|
+
text (str): строка для парсинга
|
|
95
96
|
|
|
96
97
|
Returns:
|
|
97
98
|
str: чистая строка
|
|
98
99
|
list[Span]: список спанов
|
|
99
100
|
"""
|
|
100
|
-
text, spans = convert(
|
|
101
|
+
text, spans = convert(text, latex_escape=False)
|
|
101
102
|
return text, [Span.model_validate(span, from_attributes=True) for span in spans]
|
|
File without changes
|
|
@@ -14,7 +14,7 @@ version_provider = "uv"
|
|
|
14
14
|
|
|
15
15
|
[project]
|
|
16
16
|
name = "itd-sdk"
|
|
17
|
-
version = "2.9.
|
|
17
|
+
version = "2.9.2"
|
|
18
18
|
description = "ITD client for python"
|
|
19
19
|
readme = "README.md"
|
|
20
20
|
authors = [
|
|
@@ -66,6 +66,10 @@ captcha = [
|
|
|
66
66
|
dev = [
|
|
67
67
|
"pytest>=8.4.2",
|
|
68
68
|
"python-dotenv>=1.2.1",
|
|
69
|
+
"griffe-typingdoc>=0.3.0",
|
|
70
|
+
"mkdocs>=1.6.1",
|
|
71
|
+
"mkdocs-material>=9.7.6",
|
|
72
|
+
"mkdocstrings[python]>=0.30.1"
|
|
69
73
|
]
|
|
70
74
|
|
|
71
75
|
[project.scripts]
|
itd_sdk-2.9.0/itd/core/qr.py
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
from json import loads
|
|
2
|
-
from time import sleep
|
|
3
|
-
from typing import TYPE_CHECKING
|
|
4
|
-
from uuid import UUID
|
|
5
|
-
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
7
|
-
from sseclient import SSEClient
|
|
8
|
-
|
|
9
|
-
from itd.api.auth import qr_claim, qr_start, qr_stream
|
|
10
|
-
from itd.core.logger import RICH_AVAILABLE, get_logger, iprint
|
|
11
|
-
|
|
12
|
-
if TYPE_CHECKING:
|
|
13
|
-
from itd.core.client import Client
|
|
14
|
-
|
|
15
|
-
try:
|
|
16
|
-
from qrcode import QRCode
|
|
17
|
-
|
|
18
|
-
QR_AVAILABLE = True
|
|
19
|
-
except ImportError:
|
|
20
|
-
QR_AVAILABLE = False
|
|
21
|
-
|
|
22
|
-
try:
|
|
23
|
-
from rich.status import Status
|
|
24
|
-
except ImportError:
|
|
25
|
-
pass
|
|
26
|
-
|
|
27
|
-
l = get_logger('qr')
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class ITDQRCode(BaseModel):
|
|
31
|
-
id: UUID = Field(alias='qrId')
|
|
32
|
-
payload: str
|
|
33
|
-
claim_token: str = Field(alias='claimToken')
|
|
34
|
-
expires_in: int = Field(90, alias='expiresIn')
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def auth_qr(client: 'Client'):
|
|
38
|
-
if not QR_AVAILABLE:
|
|
39
|
-
l.error('qrcode library not installed; install via `uv add itd-sdk[qrcode]`')
|
|
40
|
-
return False
|
|
41
|
-
|
|
42
|
-
itd_qr = None
|
|
43
|
-
|
|
44
|
-
def generate_qr():
|
|
45
|
-
nonlocal itd_qr
|
|
46
|
-
if status:
|
|
47
|
-
status.update('Generating QR')
|
|
48
|
-
itd_qr = ITDQRCode.model_validate(qr_start(client).json())
|
|
49
|
-
l.debug('qr code: id=%s claim_token=%s', itd_qr.id, itd_qr.claim_token)
|
|
50
|
-
qr = QRCode(border=0)
|
|
51
|
-
qr.add_data(itd_qr.payload)
|
|
52
|
-
iprint(l, 'scan QR code with mobile app:')
|
|
53
|
-
qr.print_ascii(invert=False)
|
|
54
|
-
if status:
|
|
55
|
-
status.update('Waiting for scan')
|
|
56
|
-
|
|
57
|
-
if RICH_AVAILABLE:
|
|
58
|
-
status = Status('Waiting for scan')
|
|
59
|
-
status.start()
|
|
60
|
-
else:
|
|
61
|
-
status = None
|
|
62
|
-
stream = None
|
|
63
|
-
|
|
64
|
-
try:
|
|
65
|
-
for _ in range(5):
|
|
66
|
-
generate_qr()
|
|
67
|
-
assert itd_qr
|
|
68
|
-
|
|
69
|
-
stream = qr_stream(client, qr_id=itd_qr.id, claim_token=itd_qr.claim_token)
|
|
70
|
-
l.info('start stream')
|
|
71
|
-
for event in SSEClient(stream).events():
|
|
72
|
-
res = loads(event.data)
|
|
73
|
-
l.debug('qr code status: %s', res['status'])
|
|
74
|
-
|
|
75
|
-
if res['status'] == 'approved':
|
|
76
|
-
iprint(l, 'qr code approved')
|
|
77
|
-
claim_res = qr_claim(client, qr_id=itd_qr.id, claim_token=itd_qr.claim_token)
|
|
78
|
-
client._profile.set_refresh(claim_res.cookies['refresh_token'])
|
|
79
|
-
client._profile.set_access(claim_res.json()['accessToken'])
|
|
80
|
-
stream.close()
|
|
81
|
-
return True
|
|
82
|
-
|
|
83
|
-
elif res['status'] == 'rejected':
|
|
84
|
-
l.error('qr code rejected')
|
|
85
|
-
generate_qr()
|
|
86
|
-
stream.close()
|
|
87
|
-
break
|
|
88
|
-
|
|
89
|
-
elif res['status'] == 'scanned':
|
|
90
|
-
iprint(l, 'qr code scanned')
|
|
91
|
-
if status:
|
|
92
|
-
status.update('Waiting for accept')
|
|
93
|
-
|
|
94
|
-
elif res['status'] == 'pending':
|
|
95
|
-
iprint(l, 'qr code pending')
|
|
96
|
-
|
|
97
|
-
# elif res['status'] == 'captcha_required':
|
|
98
|
-
# iprint(l, 'captcha required')
|
|
99
|
-
# if status:
|
|
100
|
-
# status.update('Solving captcha')
|
|
101
|
-
# turnstile = get_turnstile(client)
|
|
102
|
-
|
|
103
|
-
l.info('stop stream')
|
|
104
|
-
sleep(5)
|
|
105
|
-
|
|
106
|
-
l.error('all retries to auth qr exceeded')
|
|
107
|
-
return False
|
|
108
|
-
finally:
|
|
109
|
-
if status:
|
|
110
|
-
status.stop()
|
|
111
|
-
if stream:
|
|
112
|
-
stream.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|