itd-sdk 2.3.0__tar.gz → 2.4.0__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.3.0 → itd_sdk-2.4.0}/PKG-INFO +5 -3
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/README.md +2 -2
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/__init__.py +2 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/dwell.py +19 -19
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/notifications.py +1 -1
- itd_sdk-2.4.0/itd/api/platform.py +17 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/posts.py +5 -0
- itd_sdk-2.4.0/itd/api/sessions.py +23 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/base.py +64 -43
- itd_sdk-2.4.0/itd/clan.py +38 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/client.py +73 -25
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/enums.py +11 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/exceptions.py +9 -1
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/file.py +10 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/hashtag.py +10 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/logger.py +9 -11
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/notification.py +62 -24
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/post.py +39 -9
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/request.py +3 -3
- itd_sdk-2.4.0/itd/session.py +79 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/user.py +37 -12
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/utils.py +12 -2
- itd_sdk-2.4.0/itd/version.py +73 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/pyproject.toml +58 -55
- itd_sdk-2.3.0/itd/clan.py +0 -17
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/_default.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/__init__.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/auth.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/comments.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/etc.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/files.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/pins.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/polls.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/reports.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/search.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/subscription.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/users.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/api/verification.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/comment.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/pin.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/poll.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/report.py +0 -0
- {itd_sdk-2.3.0 → itd_sdk-2.4.0}/itd/span.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itd-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: ITD client for python
|
|
5
5
|
Keywords: itd,itdsocial
|
|
6
6
|
Author: firedotguy
|
|
@@ -14,18 +14,20 @@ Classifier: Framework :: Pydantic :: 2
|
|
|
14
14
|
Requires-Dist: requests~=2.32.5
|
|
15
15
|
Requires-Dist: pydantic>=2.12.5,<2.14.0
|
|
16
16
|
Requires-Dist: sseclient-py~=1.9.0
|
|
17
|
+
Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
|
|
17
18
|
Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
|
|
18
19
|
Requires-Dist: rich>=15.0.0 ; extra == 'logs'
|
|
19
20
|
Requires-Python: >=3.9
|
|
20
21
|
Project-URL: Homepage, https://itdsdk.qzz.io
|
|
21
22
|
Project-URL: Documentation, https://itdsdk.qzz.io/docs
|
|
23
|
+
Provides-Extra: date
|
|
22
24
|
Provides-Extra: filetype
|
|
23
25
|
Provides-Extra: logs
|
|
24
26
|
Description-Content-Type: text/markdown
|
|
25
27
|
|
|
26
28
|
# itd-sdk
|
|
27
29
|
Клиент ITD для python
|
|
28
|
-
Документация (beta): https://
|
|
30
|
+
Документация (beta): https://itdsdk.qzz.io/docs
|
|
29
31
|
|
|
30
32
|
## Установка
|
|
31
33
|
|
|
@@ -123,7 +125,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
|
|
|
123
125
|
## Прочее
|
|
124
126
|
- Лицезия: [MIT](./LICENSE)
|
|
125
127
|
- Автор:
|
|
126
|
-
- ИТД: [itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
128
|
+
- ИТД: [@itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
127
129
|
- ТГ: [@desicars](https://t.me/desicars)
|
|
128
130
|
|
|
129
131
|
[](https://www.star-history.com/?repos=itd-sdk%2Fitd-sdk&type=date&legend=top-left)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# itd-sdk
|
|
2
2
|
Клиент ITD для python
|
|
3
|
-
Документация (beta): https://
|
|
3
|
+
Документация (beta): https://itdsdk.qzz.io/docs
|
|
4
4
|
|
|
5
5
|
## Установка
|
|
6
6
|
|
|
@@ -98,7 +98,7 @@ fetch(c, 'метод', 'эндпоинт', {'данные': 'данные'})
|
|
|
98
98
|
## Прочее
|
|
99
99
|
- Лицезия: [MIT](./LICENSE)
|
|
100
100
|
- Автор:
|
|
101
|
-
- ИТД: [itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
101
|
+
- ИТД: [@itd_sdk](https://xn--d1ah4a.com/@itd_sdk) или [@fdg](https://xn--d1ah4a.com/@pingbot)
|
|
102
102
|
- ТГ: [@desicars](https://t.me/desicars)
|
|
103
103
|
|
|
104
104
|
[](https://www.star-history.com/?repos=itd-sdk%2Fitd-sdk&type=date&legend=top-left)
|
|
@@ -2,6 +2,7 @@ from importlib.metadata import version
|
|
|
2
2
|
|
|
3
3
|
__version__ = version("itd-sdk")
|
|
4
4
|
|
|
5
|
+
from itd.version import Changelog, Apps
|
|
5
6
|
from itd.client import Client as ITDClient, Config as ITDConfig
|
|
6
7
|
from itd.clan import Clan, TopClans
|
|
7
8
|
from itd.file import File
|
|
@@ -9,4 +10,5 @@ from itd.hashtag import Hashtag, Hashtags
|
|
|
9
10
|
from itd.notification import Notifications
|
|
10
11
|
from itd.post import Post, Posts, UserPosts, HashtagPosts, LikedPosts
|
|
11
12
|
from itd.poll import NewPoll
|
|
13
|
+
from itd.session import Sessions
|
|
12
14
|
from itd.user import User, Me
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING
|
|
3
|
-
from uuid import UUID
|
|
4
|
-
|
|
5
|
-
from itd.base import catch_errors, rate_limit
|
|
6
|
-
if TYPE_CHECKING:
|
|
7
|
-
from itd.client import Client
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@rate_limit()
|
|
11
|
-
@catch_errors()
|
|
12
|
-
def send_views(client: Client, objects: list[dict], sid: UUID):
|
|
13
|
-
return client.request('post', 'v1/i', {'e': objects, 'sid': str(sid)})
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@rate_limit()
|
|
17
|
-
@catch_errors()
|
|
18
|
-
def send_interactions(client: Client, objects: list[dict], sid: UUID):
|
|
19
|
-
return client.request('post', 'v1/x', {'e': objects, 'sid': str(sid)})
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from itd.base import catch_errors, rate_limit
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from itd.client import Client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@rate_limit()
|
|
11
|
+
@catch_errors()
|
|
12
|
+
def send_views(client: Client, objects: list[dict], sid: UUID):
|
|
13
|
+
return client.request('post', 'v1/i', {'e': objects, 'sid': str(sid)})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@rate_limit()
|
|
17
|
+
@catch_errors()
|
|
18
|
+
def send_interactions(client: Client, objects: list[dict], sid: UUID):
|
|
19
|
+
return client.request('post', 'v1/x', {'e': objects, 'sid': str(sid)})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from itd.client import Client
|
|
6
|
+
from itd.enums import AuthLevel
|
|
7
|
+
from itd.base import catch_errors, rate_limit
|
|
8
|
+
|
|
9
|
+
@catch_errors()
|
|
10
|
+
@rate_limit()
|
|
11
|
+
def get_apps(client: Client):
|
|
12
|
+
return client.request('get', 'platform/version', level=AuthLevel.NO)
|
|
13
|
+
|
|
14
|
+
@catch_errors()
|
|
15
|
+
@rate_limit()
|
|
16
|
+
def get_changelog(client: Client):
|
|
17
|
+
return client.request('get', 'platform/changelog', level=AuthLevel.NO)
|
|
@@ -105,3 +105,8 @@ def like_post(client: Client, id: UUID):
|
|
|
105
105
|
@catch_errors(NotFoundError('Post'))
|
|
106
106
|
def unlike_post(client: Client, id: UUID):
|
|
107
107
|
return client.request('delete', f'posts/{id}/like')
|
|
108
|
+
|
|
109
|
+
@rate_limit()
|
|
110
|
+
@catch_errors(ValidationError())
|
|
111
|
+
def get_stats(client: Client, ids: list[UUID]):
|
|
112
|
+
return client.request('post', 'posts/stats', {'ids': [str(id) for id in ids]}) # if not found, will return empty list
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from itd.client import Client
|
|
7
|
+
from itd.exceptions import ValidationError
|
|
8
|
+
from itd.base import catch_errors, rate_limit
|
|
9
|
+
|
|
10
|
+
@catch_errors()
|
|
11
|
+
@rate_limit()
|
|
12
|
+
def get_sessions(client: Client):
|
|
13
|
+
return client.request('get', 'v1/auth/sessions')
|
|
14
|
+
|
|
15
|
+
@catch_errors()
|
|
16
|
+
@rate_limit()
|
|
17
|
+
def delete_session(client: Client, id: UUID):
|
|
18
|
+
return client.request('delete', f'v1/auth/sessions/{id}')
|
|
19
|
+
|
|
20
|
+
@catch_errors()
|
|
21
|
+
@rate_limit()
|
|
22
|
+
def delete_all_sessions(client: Client):
|
|
23
|
+
return client.request('delete', 'v1/auth/sessions')
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import Any, Callable, TYPE_CHECKING, Iterator, TypeVar
|
|
2
|
+
from typing import Any, Callable, TYPE_CHECKING, Iterator, TypeVar, overload
|
|
3
3
|
from functools import wraps
|
|
4
4
|
from time import sleep
|
|
5
5
|
from datetime import datetime, timedelta
|
|
@@ -12,7 +12,10 @@ from pydantic_core import PydanticUndefinedType
|
|
|
12
12
|
|
|
13
13
|
from itd._default import get_default_client
|
|
14
14
|
from itd.logger import get_logger
|
|
15
|
-
from itd.exceptions import
|
|
15
|
+
from itd.exceptions import (
|
|
16
|
+
ITDException, ValidationError, RateLimitError, UnauthorizedError, AccessTokenExpiredError,
|
|
17
|
+
DEFAULT_ERRORS, AccountDeletedError
|
|
18
|
+
)
|
|
16
19
|
from itd.enums import All, ALL, DebugResponseMode, RateLimitMode, BATCH, Batch
|
|
17
20
|
if TYPE_CHECKING:
|
|
18
21
|
from itd.client import Client
|
|
@@ -20,6 +23,11 @@ if TYPE_CHECKING:
|
|
|
20
23
|
|
|
21
24
|
l = get_logger('base')
|
|
22
25
|
|
|
26
|
+
def _is_comments_on_post(value: Any, self: Any) -> bool:
|
|
27
|
+
from itd.comment import Comments
|
|
28
|
+
from itd.post import Post
|
|
29
|
+
return isinstance(value, Comments) and isinstance(self, Post)
|
|
30
|
+
|
|
23
31
|
def _getattr(self: object, name: str, default: Any | None = None) -> Any:
|
|
24
32
|
try:
|
|
25
33
|
return object.__getattribute__(self, name)
|
|
@@ -67,35 +75,28 @@ class ITDBaseModel:
|
|
|
67
75
|
|
|
68
76
|
if not TYPE_CHECKING:
|
|
69
77
|
def __getattribute__(self, name: str) -> Any:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
any(triggers.values()) and
|
|
88
|
-
_getattr(self, 'client').config.auto_load and
|
|
89
|
-
not (name == 'comments' and not _getattr(self, 'client').config.load_comments_from_post) # да я хотел сделать нормально, поставить проперти на comments и тд, но это херня кака ято крч просто нахардкожу
|
|
90
|
-
):
|
|
91
|
-
l.info('load %s.%s reason=%s', self.__class__.__name__.lower(), name,
|
|
92
|
-
next((k for k, v in triggers.items() if v))
|
|
93
|
-
)
|
|
94
|
-
self.refresh()
|
|
78
|
+
value = _getattr(self, name)
|
|
79
|
+
if (
|
|
80
|
+
name.startswith('_') or # приватный аттрибут
|
|
81
|
+
name in ('client', 'model_fields_set') or
|
|
82
|
+
not _getattr(self, '_refreshable') or # не рефрешабельная модель
|
|
83
|
+
not _getattr(self, 'client').config.auto_load or # отключено в конфиге
|
|
84
|
+
callable(value) or # функция
|
|
85
|
+
(not _getattr(self, 'client').config.load_comments_from_post and _is_comments_on_post(value, self)) or
|
|
86
|
+
(isinstance(value, ITDBaseModel) and not value._load_with_parent)
|
|
87
|
+
):
|
|
88
|
+
# l.debug('return %s as is', name)
|
|
89
|
+
return object.__getattribute__(self, name)
|
|
90
|
+
|
|
91
|
+
if isinstance(value, FieldInfo) or (isinstance(value, ITDBaseModel) and value._load_with_parent and not value._loaded) or (not _getattr(self, '_loaded') and value is None):
|
|
92
|
+
l.info('refresh %s (caused by %s)', self.__class__.__name__, name)
|
|
93
|
+
self.refresh()
|
|
94
|
+
return object.__getattribute__(self, name)
|
|
95
95
|
|
|
96
96
|
return object.__getattribute__(self, name)
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
|
|
99
100
|
T = TypeVar('T', bound=ITDBaseModel)
|
|
100
101
|
|
|
101
102
|
class ITDList[T](ITDBaseModel, list[T]):
|
|
@@ -211,18 +212,33 @@ class ITDList[T](ITDBaseModel, list[T]):
|
|
|
211
212
|
"""
|
|
212
213
|
return self.load(ALL, limit, client)
|
|
213
214
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
@overload
|
|
216
|
+
def __getitem__(self, index: int) -> T: ...
|
|
217
|
+
|
|
218
|
+
@overload
|
|
219
|
+
def __getitem__(self, index: slice) -> list[T]: ...
|
|
220
|
+
|
|
221
|
+
def __getitem__(self, index: int | slice) -> T | list[T]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
222
|
+
|
|
223
|
+
if isinstance(index, slice):
|
|
224
|
+
value: int | None = index.stop
|
|
225
|
+
else:
|
|
226
|
+
value = index
|
|
227
|
+
|
|
228
|
+
if ((value is not None and value > len(self) - 1) or value is None) and self.client.config.load_on_getitem is not None:
|
|
229
|
+
if value:
|
|
230
|
+
self._min_total = value + 1
|
|
231
|
+
|
|
232
|
+
if value is None or isinstance(self.client.config.load_on_getitem, All):
|
|
218
233
|
l.debug('getitem load all')
|
|
219
234
|
self.load_all()
|
|
220
235
|
elif isinstance(self.client.config.load_on_getitem, Batch):
|
|
221
236
|
l.debug('getitem load batch')
|
|
222
237
|
self.load(BATCH)
|
|
223
238
|
else:
|
|
224
|
-
l.debug('getitem load %s',
|
|
225
|
-
self.load(
|
|
239
|
+
l.debug('getitem load %s', value - len(self) + self.client.config.load_on_getitem)
|
|
240
|
+
self.load(value - len(self) + self.client.config.load_on_getitem)
|
|
241
|
+
|
|
226
242
|
return super().__getitem__(index)
|
|
227
243
|
|
|
228
244
|
def __next__(self) -> T:
|
|
@@ -261,7 +277,7 @@ def refresh_wrapper(func):
|
|
|
261
277
|
if self._validator:
|
|
262
278
|
validated = self._validator().model_validate(data)
|
|
263
279
|
self._fields_from_data = validated.model_fields_set
|
|
264
|
-
l.debug('
|
|
280
|
+
l.debug('fill %s', self.__class__.__name__)
|
|
265
281
|
for name, value in validated.__dict__.items():
|
|
266
282
|
setattr(self, name, value)
|
|
267
283
|
|
|
@@ -323,16 +339,17 @@ def catch_errors(*exceptions: ITDException):
|
|
|
323
339
|
):
|
|
324
340
|
if isinstance(exception, ValidationError):
|
|
325
341
|
exception.text = json.get('error', {}).get('message', 'Failed validation')
|
|
342
|
+
|
|
326
343
|
if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
|
|
327
344
|
exception.retry_after = json.get('error', {}).get('retryAfter', 0)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
client.refresh_auth()
|
|
332
|
-
finally:
|
|
333
|
-
client._refreshing = False
|
|
345
|
+
|
|
346
|
+
if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
|
|
347
|
+
client.refresh_auth()
|
|
334
348
|
return wrapper(client, *args, **kwargs)
|
|
335
349
|
|
|
350
|
+
if isinstance(exception, AccountDeletedError):
|
|
351
|
+
exception.can_restore = json.get('error', {}).get('canRestore', True)
|
|
352
|
+
|
|
336
353
|
raise exception
|
|
337
354
|
|
|
338
355
|
if client.config.debug_response == DebugResponseMode.AFTER:
|
|
@@ -369,7 +386,7 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
369
386
|
else:
|
|
370
387
|
delay = client.config._rate_limit_default
|
|
371
388
|
|
|
372
|
-
if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(
|
|
389
|
+
if datetime.now() - timedelta(seconds=delay) < client.last_actions.get(func.__name__, datetime(1990, 1, 1)):
|
|
373
390
|
delay -= (datetime.now() - client.last_actions[func.__name__]).seconds
|
|
374
391
|
l.debug('anti rate limit on %s; wait %ss', func.__name__, delay)
|
|
375
392
|
sleep(max(delay, 0))
|
|
@@ -382,8 +399,12 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
382
399
|
try:
|
|
383
400
|
return func(client, *args, **kwargs)
|
|
384
401
|
except (client.config._retry_exceptions) as e:
|
|
385
|
-
|
|
386
|
-
|
|
402
|
+
if getattr(e, 'retry_after', 0) > client.config.retry_max_retry_after:
|
|
403
|
+
l.error('too large rate limit')
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
retry_after = getattr(e, 'retry_after', client.config.retry_delay)
|
|
407
|
+
l.warning('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
|
|
387
408
|
sleep(retry_after)
|
|
388
409
|
|
|
389
410
|
return wrapper
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from itd.base import ITDBaseModel
|
|
4
|
+
from itd.logger import get_logger
|
|
5
|
+
from itd.api.etc import get_top_clans
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
l = get_logger('clan')
|
|
9
|
+
|
|
10
|
+
class Clan(BaseModel):
|
|
11
|
+
avatar: str
|
|
12
|
+
members_count: int = Field(0, alias='memberCount')
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
return self.avatar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TopClans(ITDBaseModel, list[Clan]):
|
|
19
|
+
_refreshable = False
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.load()
|
|
24
|
+
|
|
25
|
+
def load(self):
|
|
26
|
+
self.clear()
|
|
27
|
+
self.extend([Clan.model_validate(clan) for clan in get_top_clans(self.client).json()['clans']])
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def refresh(self):
|
|
31
|
+
l.warning('TopClans.refresh is deprecated and will be removed in 2.6.0. Pleas use TopClans.load')
|
|
32
|
+
self.load()
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def empty(cls):
|
|
36
|
+
instance = cls.__new__(cls)
|
|
37
|
+
super(TopClans, instance).__init__()
|
|
38
|
+
return instance
|
|
@@ -2,6 +2,10 @@ from uuid import UUID
|
|
|
2
2
|
from _io import BufferedReader
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
+
from atexit import register
|
|
6
|
+
from time import sleep
|
|
7
|
+
from threading import Thread
|
|
8
|
+
from typing import overload
|
|
5
9
|
|
|
6
10
|
from requests import Session
|
|
7
11
|
from requests.utils import default_user_agent
|
|
@@ -9,14 +13,15 @@ from requests.adapters import HTTPAdapter
|
|
|
9
13
|
from requests.exceptions import RequestException
|
|
10
14
|
|
|
11
15
|
from itd._default import _default_client, set_default_client
|
|
12
|
-
from itd.exceptions import
|
|
16
|
+
from itd.exceptions import InsufficientAuthLevelError, RateLimitError, InternalError, NotFoundError
|
|
13
17
|
from itd.hashtag import Hashtag
|
|
14
|
-
from itd.request import fetch, decode_jwt_payload
|
|
18
|
+
from itd.request import fetch, decode_jwt_payload, fetch_stream
|
|
15
19
|
from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent, AuthLevel
|
|
16
20
|
from itd.user import Me, User
|
|
17
|
-
from itd.post import DwellTracker
|
|
21
|
+
from itd.post import DwellTracker, Post
|
|
18
22
|
from itd.api.auth import refresh_token, change_password, logout
|
|
19
23
|
from itd.api.search import search
|
|
24
|
+
from itd.api.posts import get_stats
|
|
20
25
|
from itd.api.users import get_follow_status
|
|
21
26
|
from itd.utils import to_uuid, get_sdk_user_agent
|
|
22
27
|
from itd.logger import get_logger
|
|
@@ -57,12 +62,11 @@ class Config:
|
|
|
57
62
|
|
|
58
63
|
parse_mode: ParseMode = ParseMode.NO
|
|
59
64
|
|
|
60
|
-
rate_limit_wait: int | None = None # DEPRECATED
|
|
61
|
-
retry_on_rate_limits: bool | None = None # DEPRECATED
|
|
62
65
|
retry_enabled: bool = True
|
|
63
66
|
retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
|
|
64
67
|
retry_max_retries: int | None = None # none for no limit
|
|
65
68
|
retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
|
|
69
|
+
retry_max_retry_after: int = 500
|
|
66
70
|
|
|
67
71
|
bypass_auth_level: bool = False
|
|
68
72
|
|
|
@@ -70,6 +74,13 @@ class Config:
|
|
|
70
74
|
dwell_max_buffer: int = 20
|
|
71
75
|
dwell_send_interval: float = 2
|
|
72
76
|
dwell_save_on_quit: bool = True
|
|
77
|
+
dwell_wait_durations: bool = True
|
|
78
|
+
|
|
79
|
+
post_update_stats: bool = False
|
|
80
|
+
post_update_stats_interval: int = 3
|
|
81
|
+
|
|
82
|
+
view_read_speed: int = 250 # in WPM # https://scholarwithin.com/average-reading-speed
|
|
83
|
+
view_images_speed: int = 130 # https://news.mit.edu/2014/in-the-blink-of-an-eye-0116
|
|
73
84
|
|
|
74
85
|
def __post_init__(self):
|
|
75
86
|
if self.rate_limit_default:
|
|
@@ -96,28 +107,21 @@ class Config:
|
|
|
96
107
|
case _:
|
|
97
108
|
self._user_agent = self.user_agent
|
|
98
109
|
|
|
99
|
-
if self.rate_limit_wait is not None:
|
|
100
|
-
l.warning('config.rate_limit_wait is deprecated and will be removed in 2.4.0. Please use config.retry_delay')
|
|
101
|
-
self.retry_delay = self.rate_limit_wait
|
|
102
|
-
if self.retry_on_rate_limits is not None:
|
|
103
|
-
l.warning('config.retry_on_rate_limits is deprecated and will be removed in 2.4.0. Please use config.retry_enabled')
|
|
104
|
-
self.retry_enabled = self.retry_on_rate_limits
|
|
105
110
|
|
|
106
111
|
self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
|
|
107
112
|
|
|
108
113
|
|
|
109
114
|
|
|
110
115
|
class Client:
|
|
111
|
-
auth_level: AuthLevel = AuthLevel.NO
|
|
112
|
-
access_token: str | None = None
|
|
113
|
-
refresh_token: str | None = None
|
|
114
|
-
_user = None
|
|
115
|
-
_refreshing: bool = False
|
|
116
|
-
|
|
117
116
|
def __init__(self, refresh: str | None = None, access: str | None = None, config: Config = Config()):
|
|
118
117
|
l.info('init client refresh=%s access=%s', refresh is not None, access is not None)
|
|
119
118
|
self.config = config
|
|
120
119
|
self.last_actions: dict[str, datetime] = {}
|
|
120
|
+
self.auth_level: AuthLevel = AuthLevel.NO
|
|
121
|
+
self.access_token: str | None = None
|
|
122
|
+
self.refresh_token: str | None = None
|
|
123
|
+
self._user: Me | None = None
|
|
124
|
+
self.visible_posts: list[Post] = []
|
|
121
125
|
|
|
122
126
|
self.session = Session()
|
|
123
127
|
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
|
|
@@ -143,6 +147,32 @@ class Client:
|
|
|
143
147
|
else:
|
|
144
148
|
self.dwell_tracker = None
|
|
145
149
|
|
|
150
|
+
if self.config.post_update_stats:
|
|
151
|
+
self._start_update_timer()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _start_update_timer(self):
|
|
155
|
+
l.debug('start update timer')
|
|
156
|
+
if not self.config.post_update_stats_interval:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
def loop():
|
|
160
|
+
while True:
|
|
161
|
+
sleep(self.config.post_update_stats_interval)
|
|
162
|
+
self.update_post_stats()
|
|
163
|
+
|
|
164
|
+
self._thread = Thread(target=loop)
|
|
165
|
+
self._thread.daemon = True
|
|
166
|
+
self._thread.start()
|
|
167
|
+
|
|
168
|
+
def on_exit():
|
|
169
|
+
l.debug('stop update timer')
|
|
170
|
+
if self._thread:
|
|
171
|
+
self._thread.join(timeout=0)
|
|
172
|
+
|
|
173
|
+
register(on_exit)
|
|
174
|
+
|
|
175
|
+
|
|
146
176
|
def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
|
|
147
177
|
"""Сделать запрос
|
|
148
178
|
|
|
@@ -160,17 +190,29 @@ class Client:
|
|
|
160
190
|
if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
|
|
161
191
|
self.refresh_auth()
|
|
162
192
|
|
|
163
|
-
|
|
164
|
-
return fetch(self, method, url, params, files)
|
|
193
|
+
return fetch(self, method, url, params, files)
|
|
165
194
|
|
|
166
|
-
|
|
167
|
-
|
|
195
|
+
def request_sse(self, url: str):
|
|
196
|
+
l.debug('sse %s', url)
|
|
168
197
|
|
|
169
|
-
|
|
170
|
-
return _fetch()
|
|
171
|
-
except (UnauthorizedError, AccessTokenExpiredError):
|
|
198
|
+
if self.access_token is None and url != 'v1/auth/refresh':
|
|
172
199
|
self.refresh_auth()
|
|
173
|
-
|
|
200
|
+
|
|
201
|
+
return fetch_stream(self, url)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def update_post_stats(self):
|
|
205
|
+
if len(self.visible_posts) == 0:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
l.debug('update post stats count=%s', len(self.visible_posts))
|
|
209
|
+
stats: list[dict] = get_stats(self, [post.id for post in self.visible_posts]).json().get('posts', [])
|
|
210
|
+
if len(stats) != len(self.visible_posts):
|
|
211
|
+
raise NotFoundError('Post(s)')
|
|
212
|
+
|
|
213
|
+
for post in self.visible_posts:
|
|
214
|
+
post._set_stats(next((stat for stat in stats if stat['id'] == str(post.id))))
|
|
215
|
+
|
|
174
216
|
|
|
175
217
|
def refresh_auth(self) -> str:
|
|
176
218
|
"""Обновить access token
|
|
@@ -295,6 +337,12 @@ class Client:
|
|
|
295
337
|
if hashtag:
|
|
296
338
|
return hashtag[0]
|
|
297
339
|
|
|
340
|
+
@overload
|
|
341
|
+
def get_follow_status(self, users: list[User | UUID | str]) -> dict[UUID, bool]: ...
|
|
342
|
+
|
|
343
|
+
@overload
|
|
344
|
+
def get_follow_status(self, users: User | UUID | str) -> bool: ...
|
|
345
|
+
|
|
298
346
|
def get_follow_status(self, users: list[User | UUID | str] | User | UUID | str) -> dict[UUID, bool] | bool:
|
|
299
347
|
"""Получить статус подписки
|
|
300
348
|
|
|
@@ -115,6 +115,17 @@ class CommentSorting(Enum): # actually it is not working (stupid itd api)
|
|
|
115
115
|
NEW = 'new'
|
|
116
116
|
OLD = 'old'
|
|
117
117
|
|
|
118
|
+
class DeviceType(Enum):
|
|
119
|
+
DESKTOP = 'desktop'
|
|
120
|
+
MOBILE = 'mobile'
|
|
121
|
+
|
|
122
|
+
# class DeviceOS(Enum):
|
|
123
|
+
# WINDOWS = 'Windows'
|
|
124
|
+
# MACOS = 'MacOS'
|
|
125
|
+
# LINUX = 'Linux'
|
|
126
|
+
# ANDROID = 'Android'
|
|
127
|
+
# IOS = 'iOS'
|
|
128
|
+
|
|
118
129
|
class AccessType(Enum):
|
|
119
130
|
"""Типы разрешений для видимости лайков и записей на стене"""
|
|
120
131
|
NOBODY = 'nobody' # никто
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from typing import Callable
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
|
|
3
4
|
from requests import Response
|
|
4
5
|
|
|
@@ -275,8 +276,15 @@ class ProfileRequiredError(ITDException):
|
|
|
275
276
|
text = 'No profile. Please create your profile first'
|
|
276
277
|
|
|
277
278
|
|
|
279
|
+
class AccountDeletedError(ITDException):
|
|
280
|
+
code = 'ACCOUNT_DELETED'
|
|
281
|
+
text = 'Account has been deleted'
|
|
282
|
+
can_restore: bool = True
|
|
283
|
+
restore_deadline: datetime | None = None
|
|
284
|
+
|
|
285
|
+
|
|
278
286
|
DEFAULT_ERRORS = (
|
|
279
287
|
RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(),
|
|
280
288
|
AccountBannedError(), AccountTemporarilyBannedError(), InternalError(),
|
|
281
|
-
ProfileRequiredError(), RefreshTokenMissingError()
|
|
289
|
+
ProfileRequiredError(), RefreshTokenMissingError(), AccountDeletedError()
|
|
282
290
|
)
|
|
@@ -120,6 +120,16 @@ class PostAttach(ITDBaseModel):
|
|
|
120
120
|
assert self.type == AttachType.VIDEO, 'Recording video progress allowed only for videos'
|
|
121
121
|
c.dwell_tracker.record_video_progress(self._post.vs, self._post.source, self.id, played or duration, duration)
|
|
122
122
|
|
|
123
|
+
@property
|
|
124
|
+
def extension(self):
|
|
125
|
+
match self.type:
|
|
126
|
+
case AttachType.IMAGE:
|
|
127
|
+
return 'jpg'
|
|
128
|
+
case AttachType.VIDEO:
|
|
129
|
+
return 'mp4'
|
|
130
|
+
case AttachType.AUDIO:
|
|
131
|
+
return 'mp3'
|
|
132
|
+
|
|
123
133
|
|
|
124
134
|
class _PostAttachValidate(BaseModel, PostAttach):
|
|
125
135
|
pass
|
|
@@ -58,7 +58,17 @@ class _HashtagValidate(BaseModel, Hashtag):
|
|
|
58
58
|
class Hashtags(ITDBaseModel, list[Hashtag]):
|
|
59
59
|
_refreshable = False
|
|
60
60
|
|
|
61
|
+
def __init__(self, client: Client | None = None):
|
|
62
|
+
super().__init__(client)
|
|
63
|
+
self.load()
|
|
64
|
+
|
|
61
65
|
def load(self, count: int = 10):
|
|
62
66
|
self.clear()
|
|
63
67
|
self.extend([Hashtag._from_dict(hashtag) for hashtag in get_hashtags(self.client, count).json()['data']['hashtags']])
|
|
64
68
|
return self
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def empty(cls):
|
|
72
|
+
instance = cls.__new__(cls)
|
|
73
|
+
super(Hashtags, instance).__init__()
|
|
74
|
+
return instance
|