itd-sdk 2.6.0__tar.gz → 2.7.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.6.0 → itd_sdk-2.7.0}/PKG-INFO +3 -2
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/README.md +1 -1
- itd_sdk-2.7.0/itd/_credfile.py +43 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/comments.py +1 -1
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/notifications.py +0 -3
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/base.py +54 -15
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/client.py +161 -91
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/comment.py +73 -40
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/enums.py +7 -3
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/exceptions.py +1 -1
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/hashtag.py +4 -5
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/notification.py +26 -17
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/poll.py +7 -20
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/post.py +84 -76
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/request.py +2 -2
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/session.py +13 -4
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/user.py +30 -48
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/utils.py +24 -1
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/pyproject.toml +2 -1
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/__init__.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/_default.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/_limiter.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/__init__.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/auth.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/dwell.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/etc.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/files.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/pins.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/platform.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/polls.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/posts.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/reports.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/search.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/sessions.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/subscription.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/users.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/api/verification.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/clan.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/file.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/logger.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/pin.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/report.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/span.py +0 -0
- {itd_sdk-2.6.0 → itd_sdk-2.7.0}/itd/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itd-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: ITD client for python
|
|
5
5
|
Keywords: itd,itdsocial
|
|
6
6
|
Author: fi-res
|
|
@@ -16,6 +16,7 @@ Requires-Dist: pydantic>=2.12.5,<2.14.0
|
|
|
16
16
|
Requires-Dist: sseclient-py~=1.9.0
|
|
17
17
|
Requires-Dist: telegramify-markdown>=0.5.4
|
|
18
18
|
Requires-Dist: lxml>=6.1.1
|
|
19
|
+
Requires-Dist: platformdirs>=4.10.0
|
|
19
20
|
Requires-Dist: dateparser>=1.2.2 ; extra == 'date'
|
|
20
21
|
Requires-Dist: filetype>=1.2.0 ; extra == 'filetype'
|
|
21
22
|
Requires-Dist: rich>=15.0.0 ; extra == 'logs'
|
|
@@ -29,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
29
30
|
|
|
30
31
|
# itd-sdk
|
|
31
32
|
|
|
32
|
-
[](https://github.com/fi-res/ncruff)
|
|
33
|
+
[](https://github.com/fi-res/ncruff) [](https://jacobtomlinson.dev/effver)
|
|
33
34
|
|
|
34
35
|
SDK для работы с [https://xn--d1ah4a.com](итд.com) на python.
|
|
35
36
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# itd-sdk
|
|
2
2
|
|
|
3
|
-
[](https://github.com/fi-res/ncruff)
|
|
3
|
+
[](https://github.com/fi-res/ncruff) [](https://jacobtomlinson.dev/effver)
|
|
4
4
|
|
|
5
5
|
SDK для работы с [https://xn--d1ah4a.com](итд.com) на python.
|
|
6
6
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from json import dumps, loads
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
# class CredfileSession(BaseModel):
|
|
8
|
+
# id: int
|
|
9
|
+
# last_active: datetime
|
|
10
|
+
# main: bool
|
|
11
|
+
|
|
12
|
+
# @classmethod
|
|
13
|
+
# def current(cls, main: bool = False):
|
|
14
|
+
# return cls(id=getpid(), last_active=datetime.now(), main=main)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Credfile(BaseModel):
|
|
18
|
+
access: str | None = None
|
|
19
|
+
refresh: str | None = None
|
|
20
|
+
valid: bool = True
|
|
21
|
+
updated_at: datetime = Field(default_factory=datetime.now)
|
|
22
|
+
# sessions: list[CredfileSession] = Field(default_factory=lambda: [])
|
|
23
|
+
_file: Path
|
|
24
|
+
|
|
25
|
+
def flush(self):
|
|
26
|
+
self.updated_at = datetime.now()
|
|
27
|
+
self._file.write_text(dumps(self.model_dump(mode='json')))
|
|
28
|
+
|
|
29
|
+
def update(self):
|
|
30
|
+
new = self.model_validate(loads(self._file.read_text()))
|
|
31
|
+
if self.updated_at <= new.updated_at:
|
|
32
|
+
for field in Credfile.model_fields:
|
|
33
|
+
setattr(self, field, getattr(new, field))
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
# @field_validator('sessions', mode='after')
|
|
38
|
+
# def validate_sessions(self, sessions: list[CredfileSession]):
|
|
39
|
+
# for session in sessions.copy():
|
|
40
|
+
# if session.last_active < datetime.now() - timedelta(minutes=30):
|
|
41
|
+
# sessions.remove(session)
|
|
42
|
+
|
|
43
|
+
# sessions.append(CredfileSession.current(main=not any(session.main for session in sessions)))
|
|
@@ -50,7 +50,7 @@ def unlike_comment(client: Client, id: UUID):
|
|
|
50
50
|
return client.request('delete', f'comments/{id}/like')
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
@api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'))
|
|
53
|
+
@api_wrapper(NotFoundError('Comment'), AlreadyDeletedError('Comment'), ForbiddenError('delete comment'))
|
|
54
54
|
def delete_comment(client: Client, id: UUID):
|
|
55
55
|
return client.request('delete', f'comments/{id}')
|
|
56
56
|
|
|
@@ -44,10 +44,7 @@ def update_notifications_settings(client: Client, settings: NotificationsSetting
|
|
|
44
44
|
|
|
45
45
|
data = {}
|
|
46
46
|
if old:
|
|
47
|
-
print(settings.follows)
|
|
48
|
-
|
|
49
47
|
data.update(_NotificationsSettingsOld.model_validate(settings, from_attributes=True).model_dump(mode='json', by_alias=True))
|
|
50
|
-
print(settings.follows)
|
|
51
48
|
|
|
52
49
|
if new:
|
|
53
50
|
data.update(
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import inspect
|
|
4
3
|
from abc import abstractmethod
|
|
5
4
|
from functools import wraps
|
|
6
5
|
from time import sleep
|
|
7
6
|
from typing import TYPE_CHECKING, Any, Callable, Iterator, SupportsIndex, TypeVar, cast, overload
|
|
7
|
+
from uuid import UUID
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
10
11
|
from requests import Response
|
|
11
12
|
from requests.exceptions import JSONDecodeError
|
|
12
13
|
|
|
@@ -52,6 +53,7 @@ class ITDBaseModel:
|
|
|
52
53
|
self._client = client or get_default_client()
|
|
53
54
|
|
|
54
55
|
self._loaded_attrs: set[str] = set()
|
|
56
|
+
self._extra_context = {}
|
|
55
57
|
|
|
56
58
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
57
59
|
if isinstance(value, ITDBaseModel) and (client := _getattr(self, '_client')): # ai
|
|
@@ -73,13 +75,14 @@ class ITDBaseModel:
|
|
|
73
75
|
if not self._refreshable:
|
|
74
76
|
l.warning(f'{self.__class__.__name__} is not refreshable but refresh is called')
|
|
75
77
|
self.load_status = LoadStatus.LOADING
|
|
76
|
-
self._fill_from_data(self._refresh(client=client or self.client))
|
|
78
|
+
self._fill_from_data(self._refresh(client=client or self.client), context={'client': client or self.client})
|
|
77
79
|
self.load_status = LoadStatus.FULL
|
|
78
80
|
return self
|
|
79
81
|
|
|
80
|
-
def _fill_from_data(self, data: dict):
|
|
82
|
+
def _fill_from_data(self, data: dict, *, context: dict = {}):
|
|
81
83
|
assert self._validator, 'Unable to use fill_from_data without a validator'
|
|
82
|
-
|
|
84
|
+
context.update(self._extra_context)
|
|
85
|
+
validated = self._validator().model_validate(data, context=context) # ty: ignore[missing-argument]
|
|
83
86
|
self._loaded_attrs = validated.model_fields_set # значения автоматом добавляются через setattr # так значит это же тогда надо закоментить? # хз наверн
|
|
84
87
|
for name, value in validated.__dict__.items():
|
|
85
88
|
object.__setattr__(self, name, value)
|
|
@@ -87,11 +90,12 @@ class ITDBaseModel:
|
|
|
87
90
|
self._post_refresh()
|
|
88
91
|
|
|
89
92
|
@classmethod
|
|
90
|
-
def from_dict(cls, data: dict, *, client: Client | None = None):
|
|
93
|
+
def from_dict(cls, data: dict, *, context: dict = {}, client: Client | None = None):
|
|
91
94
|
instance = cls.__new__(cls)
|
|
92
95
|
ITDBaseModel.__init__(instance, client)
|
|
93
96
|
|
|
94
|
-
instance.
|
|
97
|
+
context.setdefault('client', client or instance.client)
|
|
98
|
+
instance._fill_from_data(data, context=context)
|
|
95
99
|
instance.load_status = LoadStatus.PARTIALLY
|
|
96
100
|
return instance
|
|
97
101
|
|
|
@@ -105,22 +109,44 @@ class ITDBaseModel:
|
|
|
105
109
|
value = None
|
|
106
110
|
exc = e
|
|
107
111
|
|
|
108
|
-
if name == 'is_following':
|
|
109
|
-
l.debug(_getattr(self, '_loaded_attrs'))
|
|
110
112
|
if (
|
|
111
113
|
_getattr(self, '_refreshable')
|
|
112
114
|
and not name.startswith('_')
|
|
113
115
|
and name not in ('client', 'model_fields_set', 'load_status')
|
|
114
116
|
and not callable(value)
|
|
115
117
|
and not isinstance(_getattr(type(self), name), property)
|
|
116
|
-
and name not in _getattr(self, '_loaded_attrs')
|
|
117
118
|
and _getattr(self, 'load_status') in (LoadStatus.NO, LoadStatus.PARTIALLY)
|
|
119
|
+
and name not in (_getattr(self, '_loaded_attrs') or {name})
|
|
120
|
+
and self.client.config.load_on_getattr
|
|
118
121
|
and not (isinstance(value, ITDBaseModel) and not value._load_with_parent)
|
|
119
122
|
):
|
|
120
123
|
l.info('refresh %s field=%s load_status=%s', self.__class__.__name__, name, _getattr(self, 'load_status').value)
|
|
121
124
|
self.refresh()
|
|
122
125
|
return object.__getattribute__(self, name)
|
|
123
126
|
|
|
127
|
+
annotations = {}
|
|
128
|
+
[annotations.update(_getattr(c, '__annotations__')) for c in _getattr(self, '__class__').__mro__ if _getattr(c, '__annotations__')]
|
|
129
|
+
|
|
130
|
+
if isinstance(value, FieldInfo) or (not name.startswith('_') and exc is not None and not _getattr(self, 'client').config.load_on_getattr):
|
|
131
|
+
value_type = annotations[name]
|
|
132
|
+
if 'None' in value_type:
|
|
133
|
+
l.warning('returned fake value None for %s: please load data (call refresh()) before get attributes', name)
|
|
134
|
+
return None
|
|
135
|
+
elif value_type == 'int':
|
|
136
|
+
l.warning('returned fake value 0 for %s: please load data (call refresh()) before get attributes', name)
|
|
137
|
+
return 0
|
|
138
|
+
elif value_type == 'UUID':
|
|
139
|
+
l.warning('returned fake value 00000000-0000-0000-0000-000000000010 for %s: please load data (call refresh()) before get attributes', name)
|
|
140
|
+
return UUID('00000000-0000-0000-0000-000000000000')
|
|
141
|
+
elif value_type == 'str':
|
|
142
|
+
l.warning('returned fake value "" for %s: please load data (call refresh()) before get attributes', name)
|
|
143
|
+
return ''
|
|
144
|
+
elif value_type == 'bool':
|
|
145
|
+
l.warning('returned fake value False for %s: please load data (call refresh()) before get attributes', name)
|
|
146
|
+
return False # дилема
|
|
147
|
+
l.error('cannot fake data for %s (type %s): please load data (call refresh()) before get attributes', name, value_type)
|
|
148
|
+
raise RuntimeError(f'cannot fake data for {name} (type {value_type}): please load data (call refresh()) before get attributes')
|
|
149
|
+
|
|
124
150
|
if exc is not None:
|
|
125
151
|
raise exc
|
|
126
152
|
return value
|
|
@@ -136,6 +162,7 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
136
162
|
_refreshable = False
|
|
137
163
|
has_more = True
|
|
138
164
|
idx = 0
|
|
165
|
+
_is_page_pagination: bool = False
|
|
139
166
|
|
|
140
167
|
def _fetch(self, client: Client, limit: int) -> dict:
|
|
141
168
|
return {}
|
|
@@ -157,9 +184,15 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
157
184
|
l.warning('skip load because has_more=False')
|
|
158
185
|
return []
|
|
159
186
|
|
|
160
|
-
|
|
187
|
+
default_limit = (client or self.client).config.batch_sizes._values[self.__class__.__name__.lower()]
|
|
188
|
+
limit = limit or default_limit
|
|
161
189
|
if isinstance(count, int) and count < limit:
|
|
162
190
|
limit = count
|
|
191
|
+
|
|
192
|
+
if self._is_page_pagination and limit != default_limit:
|
|
193
|
+
l.warning('unable to change limit in %s (page pagination), using default %s', self.__class__.__name__.lower(), default_limit)
|
|
194
|
+
count = limit = default_limit
|
|
195
|
+
|
|
163
196
|
l.debug('load %s count=%s limit=%s cursor=%s', self.__class__.__name__.lower(), count, limit, self.cursor)
|
|
164
197
|
|
|
165
198
|
# Batch = load one batch (limit), All = load everything, int = load exactly N
|
|
@@ -265,7 +298,7 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
265
298
|
if value is None or isinstance(self.client.config.load_on_getitem, All):
|
|
266
299
|
l.debug('getitem load all')
|
|
267
300
|
self.load_all()
|
|
268
|
-
elif isinstance(self.client.config.load_on_getitem, Batch):
|
|
301
|
+
elif self._is_page_pagination or isinstance(self.client.config.load_on_getitem, Batch):
|
|
269
302
|
l.debug('getitem load batch')
|
|
270
303
|
self.load(BATCH)
|
|
271
304
|
else:
|
|
@@ -279,7 +312,7 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
279
312
|
if getattr(self, 'total', None) and self.idx >= self.total:
|
|
280
313
|
raise StopIteration()
|
|
281
314
|
if self.idx >= len(self) and (self.has_more or self.client.config.force_load_lists):
|
|
282
|
-
l.debug('not enough items to call next
|
|
315
|
+
l.debug('not enough items to call next, load')
|
|
283
316
|
self.load(self.client.config.load_on_iter)
|
|
284
317
|
if self.idx >= len(self):
|
|
285
318
|
raise StopIteration()
|
|
@@ -297,6 +330,11 @@ class ITDList(ITDBaseModel, list[T]):
|
|
|
297
330
|
def all(self) -> list[T]:
|
|
298
331
|
return self.load_all()
|
|
299
332
|
|
|
333
|
+
def clear(self):
|
|
334
|
+
super().clear()
|
|
335
|
+
self.cursor = None
|
|
336
|
+
self.has_more = True
|
|
337
|
+
|
|
300
338
|
|
|
301
339
|
def _filter_bytes(args: tuple):
|
|
302
340
|
filtered = []
|
|
@@ -385,9 +423,10 @@ def api_wrapper(*exceptions: ITDException):
|
|
|
385
423
|
if isinstance(exception, RateLimitError) and isinstance(json.get('error'), dict):
|
|
386
424
|
exception.retry_after = json.get('error', {}).get('retryAfter', 0)
|
|
387
425
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
426
|
+
# now checks token before request
|
|
427
|
+
# if isinstance(exception, (UnauthorizedError, AccessTokenExpiredError)) and client.refresh_token:
|
|
428
|
+
# client.refresh_auth()
|
|
429
|
+
# return wrapper(client, *args, **kwargs)
|
|
391
430
|
|
|
392
431
|
if isinstance(exception, AccountDeletedError):
|
|
393
432
|
exception.can_restore = json.get('error', {}).get('canRestore', True)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from _io import BufferedReader
|
|
2
2
|
from atexit import register
|
|
3
3
|
from dataclasses import dataclass, field
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from json import dumps, loads
|
|
7
|
+
from pathlib import Path
|
|
5
8
|
from threading import Thread
|
|
6
9
|
from time import sleep
|
|
7
10
|
from typing import Callable, Literal, overload
|
|
@@ -12,19 +15,19 @@ from requests import Session
|
|
|
12
15
|
from requests.adapters import HTTPAdapter
|
|
13
16
|
from requests.utils import default_user_agent
|
|
14
17
|
|
|
15
|
-
from itd import
|
|
18
|
+
from itd._credfile import Credfile
|
|
16
19
|
from itd._default import _default_client, set_default_client
|
|
17
20
|
from itd.api.auth import change_password, logout, refresh_token
|
|
18
21
|
from itd.api.posts import get_stats
|
|
19
22
|
from itd.api.search import search
|
|
20
|
-
from itd.enums import BATCH, All, AuthLevel, Batch, DebugResponseMode, ParseMode,
|
|
21
|
-
from itd.exceptions import InsufficientAuthLevelError, NotFoundError, RateLimitError
|
|
23
|
+
from itd.enums import BATCH, All, AuthLevel, Batch, DebugResponseMode, ParseMode, Role, UserAgent, ViewReason
|
|
24
|
+
from itd.exceptions import InsufficientAuthLevelError, NotFoundError, RateLimitError, SessionExpiredError, SessionNotFoundError, SessionRevokedError
|
|
22
25
|
from itd.hashtag import Hashtag, Hashtags
|
|
23
26
|
from itd.logger import get_logger
|
|
24
27
|
from itd.post import DwellTracker, Post
|
|
25
28
|
from itd.request import decode_jwt_payload, fetch, fetch_stream
|
|
26
29
|
from itd.user import Me, User, Users, get_follow_status
|
|
27
|
-
from itd.utils import get_sdk_user_agent
|
|
30
|
+
from itd.utils import get_credfile, get_sdk_user_agent, shorten_token
|
|
28
31
|
|
|
29
32
|
l = get_logger('client') # noqa: E741
|
|
30
33
|
|
|
@@ -75,13 +78,6 @@ class Config:
|
|
|
75
78
|
# ^ это если что просто мысли, не обращайте внимания
|
|
76
79
|
client_type: Literal['client', 'bot', 'onetime'] = 'onetime'
|
|
77
80
|
|
|
78
|
-
rate_limit: RateLimitMode | None = None # deprecated
|
|
79
|
-
rate_limit_default: int | None = None # deprecated
|
|
80
|
-
rate_limit_actions: dict[str, float | int] | None = None # deprecated
|
|
81
|
-
anti_rate_limit: bool | None = None # deprecated
|
|
82
|
-
burst_requests: bool | None = None # deprecated
|
|
83
|
-
anti_ip_ban: bool | None = None # deprecated
|
|
84
|
-
limit_coefficient: float | None = None # deprecated
|
|
85
81
|
auto_acquire: bool | None = None
|
|
86
82
|
|
|
87
83
|
# enable_logging: bool | None = None
|
|
@@ -91,8 +87,8 @@ class Config:
|
|
|
91
87
|
|
|
92
88
|
userposts_add_pinned_post: bool = True
|
|
93
89
|
|
|
94
|
-
# load_on_init: bool =
|
|
95
|
-
|
|
90
|
+
# load_on_init: bool = False
|
|
91
|
+
load_on_getattr: bool = True
|
|
96
92
|
auto_load: bool | None = None
|
|
97
93
|
load_on_getitem: int | All | Batch | None = 1
|
|
98
94
|
load_on_iter: int | All | Batch | None = BATCH
|
|
@@ -105,7 +101,6 @@ class Config:
|
|
|
105
101
|
timeout_file_download: float | None = None
|
|
106
102
|
|
|
107
103
|
url: str = 'https://xn--d1ah4a.com/api'
|
|
108
|
-
url_api: str | None = None # deprecated
|
|
109
104
|
user_agent: UserAgent | str = UserAgent.BROWSER
|
|
110
105
|
solve_challenge: bool = True
|
|
111
106
|
|
|
@@ -116,7 +111,7 @@ class Config:
|
|
|
116
111
|
retry_enabled: bool | None = None
|
|
117
112
|
retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in response
|
|
118
113
|
retry_max_retries: int | None = 10 # none for no limit
|
|
119
|
-
retry_exceptions: tuple[type[Exception]] = field(default_factory=lambda: (RateLimitError,))
|
|
114
|
+
retry_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: (RateLimitError,))
|
|
120
115
|
retry_max_retry_after: int = 500
|
|
121
116
|
|
|
122
117
|
bypass_auth_level: bool = False
|
|
@@ -126,6 +121,11 @@ class Config:
|
|
|
126
121
|
dwell_send_interval: float = 2
|
|
127
122
|
dwell_save_on_quit: bool = True
|
|
128
123
|
dwell_wait_durations: bool = False
|
|
124
|
+
dwell_max_duration: int = 30000
|
|
125
|
+
dwell_min_duration: int = 250
|
|
126
|
+
dwell_check_active: bool | None = None
|
|
127
|
+
dwell_check_active_interval: float = 5
|
|
128
|
+
dwell_inactive_timeout: int = 30
|
|
129
129
|
post_view_increment: bool = False
|
|
130
130
|
post_auto_view: bool = True # view when called post.set_invisible()
|
|
131
131
|
|
|
@@ -140,10 +140,6 @@ class Config:
|
|
|
140
140
|
refresh_token_cookie_name: str = 'refresh_token'
|
|
141
141
|
|
|
142
142
|
def __post_init__(self):
|
|
143
|
-
if self.url_api is not None:
|
|
144
|
-
l.warning('config.url_api is deprecated and will be removed in 2.7.0. Please use config.url.')
|
|
145
|
-
self.url = self.url_api
|
|
146
|
-
|
|
147
143
|
match self.user_agent:
|
|
148
144
|
case UserAgent.DEFAULT:
|
|
149
145
|
self._user_agent = default_user_agent()
|
|
@@ -156,39 +152,6 @@ class Config:
|
|
|
156
152
|
case _:
|
|
157
153
|
self._user_agent = self.user_agent
|
|
158
154
|
|
|
159
|
-
if self.dwell_wait_durations:
|
|
160
|
-
l.warning('config.dwell_wait_durations is deprecated and will be removed in 2.6.0.')
|
|
161
|
-
|
|
162
|
-
if self.rate_limit is not None:
|
|
163
|
-
l.warning('config.rate_limit is deprecated and will be removed in 2.7.0.')
|
|
164
|
-
if self.rate_limit_default is not None:
|
|
165
|
-
l.warning('config.rate_limit_default is deprecated and will be removed in 2.7.0.')
|
|
166
|
-
if self.rate_limit_actions is not None:
|
|
167
|
-
l.warning('config.rate_limit_actions is deprecated and will be removed in 2.7.0.')
|
|
168
|
-
if self.anti_ip_ban is not None:
|
|
169
|
-
l.warning(
|
|
170
|
-
f'config.anti_ip_ban is deprecated and will be removed in 2.7.0. '
|
|
171
|
-
f'Please use set_limiter_config(LimiterConfig(ip_limiter={"IPRateLimiter" if self.anti_ip_ban else None})).'
|
|
172
|
-
)
|
|
173
|
-
set_limiter_config(LimiterConfig(ip_limiter=IPRateLimiter() if self.anti_ip_ban else None))
|
|
174
|
-
if self.anti_rate_limit is not None:
|
|
175
|
-
l.warning(
|
|
176
|
-
f'config.anti_rate_limit is depreacred and will be removed in 2.7.0. '
|
|
177
|
-
f'Please use set_limiter_config(limiter={"HalfRateLimiter" if self.anti_rate_limit else None}).'
|
|
178
|
-
)
|
|
179
|
-
set_limiter_config(LimiterConfig(limiter=HalfRateLimiter if self.anti_rate_limit else None))
|
|
180
|
-
if self.burst_requests is not None:
|
|
181
|
-
l.warning(
|
|
182
|
-
f'config.anti_rate_limit is depreacred and will be removed in 2.7.0. '
|
|
183
|
-
f'Please use set_limiter_config(limiter={"BurstRateLimiter" if self.burst_requests else None}).'
|
|
184
|
-
)
|
|
185
|
-
set_limiter_config(LimiterConfig(limiter=BurstRateLimiter if self.burst_requests else None))
|
|
186
|
-
if self.limit_coefficient is not None:
|
|
187
|
-
l.warning(
|
|
188
|
-
'config.limit_coefficient is deprecated and will be removed in 2.7.0. '
|
|
189
|
-
'Please define your own rate limiter that will override sync function of RateLimiter class and apply coefficient to remaining.'
|
|
190
|
-
)
|
|
191
|
-
|
|
192
155
|
if self.timeout is None:
|
|
193
156
|
match self.client_type:
|
|
194
157
|
case 'onetime':
|
|
@@ -210,8 +173,17 @@ class Config:
|
|
|
210
173
|
else:
|
|
211
174
|
self._post_update_stats = self.post_update_stats
|
|
212
175
|
|
|
176
|
+
if self.dwell_check_active is None:
|
|
177
|
+
self._dwell_check_active = self.client_type == 'client'
|
|
178
|
+
else:
|
|
179
|
+
self._dwell_check_active = self.dwell_check_active
|
|
180
|
+
|
|
213
181
|
if self.load_comments_from_post is not None:
|
|
214
|
-
l.warning('load_comments_from_post is deprecated and will be removed in 2.7.0.')
|
|
182
|
+
l.warning('config.load_comments_from_post is deprecated and will be removed in 2.7.0.')
|
|
183
|
+
|
|
184
|
+
if self.auto_load is not None:
|
|
185
|
+
l.warning('config.auto_load is deprecated and will be removed in 2.8.0. Please use load_on_getattr.')
|
|
186
|
+
self.load_on_getattr = self.auto_load
|
|
215
187
|
|
|
216
188
|
|
|
217
189
|
class AccessToken(BaseModel):
|
|
@@ -239,8 +211,10 @@ class Client:
|
|
|
239
211
|
self.access_token: str | None = None
|
|
240
212
|
self.access_token_data: AccessToken | None = None
|
|
241
213
|
self.refresh_token: str | None = None
|
|
242
|
-
self._user: Me | None = None
|
|
243
214
|
self.visible_posts: list[Post] = []
|
|
215
|
+
self._visible_posts_buffer: list[Post] = []
|
|
216
|
+
self.last_active = datetime.now()
|
|
217
|
+
self._credfile: Credfile | None = None
|
|
244
218
|
|
|
245
219
|
self.session = Session()
|
|
246
220
|
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=10, pool_block=False) # idk what is this, (claude added) just for better stability
|
|
@@ -248,7 +222,7 @@ class Client:
|
|
|
248
222
|
|
|
249
223
|
if access:
|
|
250
224
|
self.auth_level = AuthLevel.ACCESS
|
|
251
|
-
self.
|
|
225
|
+
self.token = access.replace('Bearer ', '')
|
|
252
226
|
|
|
253
227
|
if refresh:
|
|
254
228
|
self.session.cookies.set(config.refresh_token_cookie_name, refresh, path='/')
|
|
@@ -260,14 +234,117 @@ class Client:
|
|
|
260
234
|
if _default_client is None or config.is_default:
|
|
261
235
|
set_default_client(self)
|
|
262
236
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
self.dwell_tracker._start_timer()
|
|
266
|
-
else:
|
|
267
|
-
self.dwell_tracker = None
|
|
237
|
+
self.dwell_tracker = DwellTracker(self)
|
|
238
|
+
self.dwell_tracker._start_timer()
|
|
268
239
|
|
|
269
|
-
if self.config.
|
|
240
|
+
if self.config._post_update_stats:
|
|
270
241
|
self._start_update_timer()
|
|
242
|
+
if self.config._dwell_check_active:
|
|
243
|
+
self._start_check_active_timer()
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_file(cls, name: str, config: Config = Config()):
|
|
247
|
+
credfile = get_credfile(name)
|
|
248
|
+
l.debug('get credentials file refresh=%s access=%s', shorten_token(credfile.refresh), shorten_token(credfile.access))
|
|
249
|
+
if not credfile.valid:
|
|
250
|
+
l.warning('last refresh token was expired or not found. Please enter a new one:')
|
|
251
|
+
update = True
|
|
252
|
+
else:
|
|
253
|
+
update = credfile.refresh is None
|
|
254
|
+
|
|
255
|
+
if update:
|
|
256
|
+
try:
|
|
257
|
+
from rich.prompt import Prompt
|
|
258
|
+
except ImportError:
|
|
259
|
+
credfile.refresh = input('refresh token: ')
|
|
260
|
+
else:
|
|
261
|
+
credfile.refresh = Prompt.ask('[cyan]refresh token[/]')
|
|
262
|
+
|
|
263
|
+
instance = cls(credfile.refresh, credfile.access, config=config)
|
|
264
|
+
if instance.access_token_data and instance.access_token_data.expired_at < datetime.now():
|
|
265
|
+
instance.access_token = instance.access_token_data = None
|
|
266
|
+
instance._credfile = credfile
|
|
267
|
+
if update:
|
|
268
|
+
instance._update_file()
|
|
269
|
+
|
|
270
|
+
def _update_file(self, valid: bool = True):
|
|
271
|
+
if self._credfile is None:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
l.debug('update credentials file refresh=%s access=%s', shorten_token(self.refresh_token), shorten_token(self.access_token))
|
|
275
|
+
if not valid:
|
|
276
|
+
l.warning('mark %s as not valid', shorten_token(self.refresh_token))
|
|
277
|
+
|
|
278
|
+
self._credfile.access = self.access_token
|
|
279
|
+
self._credfile.refresh = self.refresh_token
|
|
280
|
+
self._credfile.valid = valid
|
|
281
|
+
self._credfile.flush()
|
|
282
|
+
|
|
283
|
+
def refresh_auth(self, force: bool = False) -> str:
|
|
284
|
+
"""Обновить access token
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
str: Токен
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
l.debug('refresh access_token')
|
|
291
|
+
if not force and self._credfile and self._credfile.update():
|
|
292
|
+
self.token = self._credfile.access
|
|
293
|
+
self.refresh_token = self._credfile.refresh
|
|
294
|
+
|
|
295
|
+
if self.access_token:
|
|
296
|
+
assert self.access_token_data
|
|
297
|
+
if self.access_token_data.expired_at > datetime.now():
|
|
298
|
+
l.debug('update access_token from credfile')
|
|
299
|
+
return self.access_token
|
|
300
|
+
else:
|
|
301
|
+
l.info('credfile access_token expired')
|
|
302
|
+
else:
|
|
303
|
+
l.info('crefile access_token is none')
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
res = refresh_token(self)
|
|
307
|
+
except (SessionExpiredError, SessionNotFoundError, SessionRevokedError):
|
|
308
|
+
self._update_file(valid=False)
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
self.token = res.json().get('accessToken') or res.json()['token']
|
|
312
|
+
if 'refresh_token' in res.cookies:
|
|
313
|
+
self.refresh_token = res.cookies['refresh_token']
|
|
314
|
+
self._update_file()
|
|
315
|
+
|
|
316
|
+
return self.token
|
|
317
|
+
|
|
318
|
+
def _start_check_active_timer(self):
|
|
319
|
+
l.debug('start check active timer')
|
|
320
|
+
if not self.config.dwell_check_active_interval:
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
def loop():
|
|
324
|
+
while True:
|
|
325
|
+
sleep(self.config.dwell_check_active_interval)
|
|
326
|
+
if self.last_active + timedelta(seconds=self.config.dwell_inactive_timeout) < datetime.now():
|
|
327
|
+
self._visible_posts_buffer = self.visible_posts.copy()
|
|
328
|
+
for post in self._visible_posts_buffer:
|
|
329
|
+
post.set_invisible(reason=ViewReason.INACTIVE)
|
|
330
|
+
elif self._visible_posts_buffer:
|
|
331
|
+
for post in self._visible_posts_buffer:
|
|
332
|
+
post.set_visible()
|
|
333
|
+
self._visible_posts_buffer.clear()
|
|
334
|
+
|
|
335
|
+
self._check_active_thread = Thread(target=loop)
|
|
336
|
+
self._check_active_thread.daemon = True
|
|
337
|
+
self._check_active_thread.start()
|
|
338
|
+
|
|
339
|
+
def on_exit():
|
|
340
|
+
l.debug('stop check active timer')
|
|
341
|
+
if self._check_active_thread:
|
|
342
|
+
self._check_active_thread.join(timeout=0)
|
|
343
|
+
|
|
344
|
+
register(on_exit)
|
|
345
|
+
|
|
346
|
+
def set_active(self): # call when user is active (scroll, move etc)
|
|
347
|
+
self.last_active = datetime.now()
|
|
271
348
|
|
|
272
349
|
def _start_update_timer(self):
|
|
273
350
|
l.debug('start update timer')
|
|
@@ -279,14 +356,14 @@ class Client:
|
|
|
279
356
|
sleep(self.config.post_update_stats_interval)
|
|
280
357
|
self.update_post_stats()
|
|
281
358
|
|
|
282
|
-
self.
|
|
283
|
-
self.
|
|
284
|
-
self.
|
|
359
|
+
self._update_thread = Thread(target=loop)
|
|
360
|
+
self._update_thread.daemon = True
|
|
361
|
+
self._update_thread.start()
|
|
285
362
|
|
|
286
363
|
def on_exit():
|
|
287
364
|
l.debug('stop update timer')
|
|
288
|
-
if self.
|
|
289
|
-
self.
|
|
365
|
+
if self._update_thread:
|
|
366
|
+
self._update_thread.join(timeout=0)
|
|
290
367
|
|
|
291
368
|
register(on_exit)
|
|
292
369
|
|
|
@@ -304,7 +381,11 @@ class Client:
|
|
|
304
381
|
if level > self.auth_level and not self.config.bypass_auth_level:
|
|
305
382
|
raise InsufficientAuthLevelError(self.auth_level, level)
|
|
306
383
|
|
|
307
|
-
if
|
|
384
|
+
if (
|
|
385
|
+
level >= AuthLevel.ACCESS
|
|
386
|
+
and (self.access_token is None or (self.access_token_data and self.access_token_data.expired_at <= datetime.now()))
|
|
387
|
+
and url != 'v1/auth/refresh'
|
|
388
|
+
):
|
|
308
389
|
self.refresh_auth()
|
|
309
390
|
|
|
310
391
|
return fetch(self, method, url, params, files)
|
|
@@ -334,39 +415,28 @@ class Client:
|
|
|
334
415
|
assert self.access_token, 'Access token not refreshed yet'
|
|
335
416
|
return self.access_token
|
|
336
417
|
|
|
418
|
+
@token.setter
|
|
419
|
+
def token(self, token: str | None):
|
|
420
|
+
self.access_token = token
|
|
421
|
+
if token is None:
|
|
422
|
+
self.access_token_data = None
|
|
423
|
+
else:
|
|
424
|
+
self.access_token_data = AccessToken.model_validate(decode_jwt_payload(token))
|
|
425
|
+
|
|
337
426
|
@property
|
|
338
427
|
def user_id(self) -> UUID:
|
|
339
428
|
assert self.access_token_data
|
|
340
429
|
return self.access_token_data.subject_id
|
|
341
430
|
|
|
342
|
-
@
|
|
431
|
+
@cached_property
|
|
343
432
|
def user(self) -> Me:
|
|
344
|
-
|
|
345
|
-
self._user = Me(self)
|
|
346
|
-
return self._user
|
|
433
|
+
return Me(self)
|
|
347
434
|
|
|
348
435
|
def _process_exc_callbacks(self, exception: Exception):
|
|
349
436
|
# l.debug([v for k, v in self.config.on_exceptions.items() if k in exception.__class__.mro()])
|
|
350
437
|
for callback in [v for k, v in self.config.on_exceptions.items() if k in exception.__class__.mro()]:
|
|
351
438
|
callback(exception)
|
|
352
439
|
|
|
353
|
-
def refresh_auth(self) -> str:
|
|
354
|
-
"""Обновить access token
|
|
355
|
-
|
|
356
|
-
Returns:
|
|
357
|
-
str: Токен
|
|
358
|
-
"""
|
|
359
|
-
l.debug('refresh token')
|
|
360
|
-
|
|
361
|
-
res = refresh_token(self)
|
|
362
|
-
res.raise_for_status()
|
|
363
|
-
|
|
364
|
-
self.access_token = res.json().get('accessToken') or res.json()['token']
|
|
365
|
-
self.access_token_data = AccessToken.model_validate(decode_jwt_payload(self.access_token))
|
|
366
|
-
|
|
367
|
-
assert self.access_token
|
|
368
|
-
return self.access_token
|
|
369
|
-
|
|
370
440
|
def logout(self):
|
|
371
441
|
"""Выход из аккаунта"""
|
|
372
442
|
res = logout(self)
|
|
@@ -479,5 +549,5 @@ class Client:
|
|
|
479
549
|
return get_follow_status(users)
|
|
480
550
|
|
|
481
551
|
|
|
482
|
-
def init_client(
|
|
483
|
-
return Client(
|
|
552
|
+
def init_client(name: str | None = None, config: Config = Config()):
|
|
553
|
+
return Client.from_file(name or 'default', config=config)
|