itd-sdk 2.1.2__tar.gz → 2.3.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.1.2 → itd_sdk-2.3.0}/PKG-INFO +1 -2
- itd_sdk-2.3.0/itd/api/auth.py +35 -0
- itd_sdk-2.3.0/itd/api/dwell.py +19 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/hashtags.py +3 -2
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/search.py +2 -1
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/base.py +10 -5
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/client.py +56 -11
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/comment.py +9 -1
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/enums.py +48 -6
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/exceptions.py +31 -5
- itd_sdk-2.3.0/itd/file.py +160 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/post.py +241 -24
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/pyproject.toml +3 -4
- itd_sdk-2.1.2/itd/api/auth.py +0 -22
- itd_sdk-2.1.2/itd/file.py +0 -94
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/README.md +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/__init__.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/_default.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/__init__.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/comments.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/etc.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/files.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/notifications.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/pins.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/polls.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/posts.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/reports.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/subscription.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/users.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/verification.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/clan.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/hashtag.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/logger.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/notification.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/pin.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/poll.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/report.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/request.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/span.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/user.py +0 -0
- {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: itd-sdk
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: ITD client for python
|
|
5
5
|
Keywords: itd,itdsocial
|
|
6
6
|
Author: firedotguy
|
|
@@ -11,7 +11,6 @@ Classifier: Natural Language :: Russian
|
|
|
11
11
|
Classifier: Programming Language :: Python
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Classifier: Framework :: Pydantic :: 2
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
14
|
Requires-Dist: requests~=2.32.5
|
|
16
15
|
Requires-Dist: pydantic>=2.12.5,<2.14.0
|
|
17
16
|
Requires-Dist: sseclient-py~=1.9.0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from requests import Response
|
|
5
|
+
|
|
6
|
+
from itd.base import catch_errors, rate_limit
|
|
7
|
+
from itd.enums import AuthLevel
|
|
8
|
+
from itd.exceptions import (
|
|
9
|
+
InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError,
|
|
10
|
+
SessionExpiredError, SessionRevokedError, InvalidCredentials, CaptchaFailedError,
|
|
11
|
+
EmailDomainNotAllowed
|
|
12
|
+
)
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from itd.client import Client
|
|
15
|
+
|
|
16
|
+
@rate_limit()
|
|
17
|
+
@catch_errors(SessionExpiredError(), SessionNotFoundError(), SessionRevokedError())
|
|
18
|
+
def refresh_token(client: Client) -> Response:
|
|
19
|
+
return client.request('post', 'v1/auth/refresh', level=AuthLevel.REFRESH)
|
|
20
|
+
|
|
21
|
+
@rate_limit()
|
|
22
|
+
@catch_errors(InvalidPasswordError(), SamePasswordError(), InvalidOldPasswordError())
|
|
23
|
+
def change_password(client: Client, old: str, new: str) -> Response:
|
|
24
|
+
return client.request('post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old})
|
|
25
|
+
|
|
26
|
+
@rate_limit()
|
|
27
|
+
@catch_errors()
|
|
28
|
+
def logout(client: Client) -> Response:
|
|
29
|
+
return client.request('post', 'v1/auth/logout', level=AuthLevel.REFRESH)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@rate_limit()
|
|
33
|
+
@catch_errors(InvalidCredentials(), CaptchaFailedError(), EmailDomainNotAllowed())
|
|
34
|
+
def sign_in(client: Client, email: str, password: str, turnstile: str):
|
|
35
|
+
return client.request('post', 'v1/auth/sign-in', {'email': email, 'password': password, 'turnstileToken': turnstile}, level=AuthLevel.NO)
|
|
@@ -0,0 +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)})
|
|
@@ -4,15 +4,16 @@ from typing import TYPE_CHECKING
|
|
|
4
4
|
|
|
5
5
|
from itd.exceptions import TooLargeError, NotFoundError
|
|
6
6
|
from itd.base import catch_errors, rate_limit
|
|
7
|
+
from itd.enums import AuthLevel
|
|
7
8
|
if TYPE_CHECKING:
|
|
8
9
|
from itd.client import Client
|
|
9
10
|
|
|
10
11
|
@rate_limit()
|
|
11
12
|
@catch_errors()
|
|
12
13
|
def get_hashtags(client: Client, limit: int = 10):
|
|
13
|
-
return client.request('get', 'hashtags/trending', {'limit': limit})
|
|
14
|
+
return client.request('get', 'hashtags/trending', {'limit': limit}, level=AuthLevel.NO)
|
|
14
15
|
|
|
15
16
|
@rate_limit()
|
|
16
17
|
@catch_errors(TooLargeError('Hashtag'), NotFoundError('Hashtag', json_check=lambda json: json.get('data', {}).get('hashtag', '') is None))
|
|
17
18
|
def get_posts_by_hashtag(client: Client, hashtag: str, cursor: UUID | None = None, limit: int = 20):
|
|
18
|
-
return client.request('get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor})
|
|
19
|
+
return client.request('get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor}, level=AuthLevel.NO)
|
|
@@ -4,9 +4,10 @@ from typing import TYPE_CHECKING
|
|
|
4
4
|
if TYPE_CHECKING:
|
|
5
5
|
from itd.client import Client
|
|
6
6
|
from itd.exceptions import ValidationError
|
|
7
|
+
from itd.enums import AuthLevel
|
|
7
8
|
from itd.base import catch_errors, rate_limit
|
|
8
9
|
|
|
9
10
|
@rate_limit(None, 0.5, 3)
|
|
10
11
|
@catch_errors(ValidationError())
|
|
11
12
|
def search(client: Client, query: str, user_limit: int = 5, hashtag_limit: int = 5):
|
|
12
|
-
return client.request('get', 'search', {'userLimit': user_limit, 'hashtagLimit': hashtag_limit, 'q': query})
|
|
13
|
+
return client.request('get', 'search', {'userLimit': user_limit, 'hashtagLimit': hashtag_limit, 'q': query}, level=AuthLevel.NO)
|
|
@@ -53,6 +53,9 @@ class ITDBaseModel:
|
|
|
53
53
|
value._client = client
|
|
54
54
|
object.__setattr__(self, name, value)
|
|
55
55
|
|
|
56
|
+
def _post_refresh(self):
|
|
57
|
+
pass
|
|
58
|
+
|
|
56
59
|
@property
|
|
57
60
|
def client(self) -> Client:
|
|
58
61
|
return self._client
|
|
@@ -147,7 +150,6 @@ class ITDList[T](ITDBaseModel, list[T]):
|
|
|
147
150
|
length = len(objects)
|
|
148
151
|
if objects and (client or self.client).config.userposts_add_pinned_post and length == batch + 1: # skip pinned post
|
|
149
152
|
length -= 1
|
|
150
|
-
l.debug('%s', objects)
|
|
151
153
|
|
|
152
154
|
if left is not None:
|
|
153
155
|
left -= length
|
|
@@ -265,6 +267,7 @@ def refresh_wrapper(func):
|
|
|
265
267
|
|
|
266
268
|
# self._loading = False
|
|
267
269
|
self._loaded = True
|
|
270
|
+
self._post_refresh()
|
|
268
271
|
|
|
269
272
|
return wrapper
|
|
270
273
|
|
|
@@ -293,6 +296,8 @@ def catch_errors(*exceptions: ITDException):
|
|
|
293
296
|
|
|
294
297
|
assert isinstance(res, Response)
|
|
295
298
|
if res.status_code == 204:
|
|
299
|
+
if client.config.debug_response != DebugResponseMode.NO:
|
|
300
|
+
l.debug('no response')
|
|
296
301
|
return res
|
|
297
302
|
|
|
298
303
|
if client.config.debug_response == DebugResponseMode.BEFORE:
|
|
@@ -370,15 +375,15 @@ def rate_limit(delay_min: float | None = None, delay_mid: float | None = None, d
|
|
|
370
375
|
sleep(max(delay, 0))
|
|
371
376
|
client.last_actions[func.__name__] = datetime.now()
|
|
372
377
|
|
|
373
|
-
if not client.config.
|
|
378
|
+
if not client.config.retry_enabled:
|
|
374
379
|
return func(client, *args, **kwargs)
|
|
375
380
|
|
|
376
381
|
while True:
|
|
377
382
|
try:
|
|
378
383
|
return func(client, *args, **kwargs)
|
|
379
|
-
except (
|
|
380
|
-
retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.
|
|
381
|
-
l.info('
|
|
384
|
+
except (client.config._retry_exceptions) as e:
|
|
385
|
+
retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.retry_delay
|
|
386
|
+
l.info('%s on %s: wait %ss', e.__class__.__name__, func.__name__, retry_after)
|
|
382
387
|
sleep(retry_after)
|
|
383
388
|
|
|
384
389
|
return wrapper
|
|
@@ -6,13 +6,15 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from requests import Session
|
|
7
7
|
from requests.utils import default_user_agent
|
|
8
8
|
from requests.adapters import HTTPAdapter
|
|
9
|
+
from requests.exceptions import RequestException
|
|
9
10
|
|
|
10
11
|
from itd._default import _default_client, set_default_client
|
|
11
|
-
from itd.exceptions import UnauthorizedError, InsufficientAuthLevelError, AccessTokenExpiredError
|
|
12
|
+
from itd.exceptions import UnauthorizedError, InsufficientAuthLevelError, AccessTokenExpiredError, RateLimitError, InternalError
|
|
12
13
|
from itd.hashtag import Hashtag
|
|
13
14
|
from itd.request import fetch, decode_jwt_payload
|
|
14
|
-
from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent
|
|
15
|
+
from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent, AuthLevel
|
|
15
16
|
from itd.user import Me, User
|
|
17
|
+
from itd.post import DwellTracker
|
|
16
18
|
from itd.api.auth import refresh_token, change_password, logout
|
|
17
19
|
from itd.api.search import search
|
|
18
20
|
from itd.api.users import get_follow_status
|
|
@@ -28,25 +30,46 @@ class Config:
|
|
|
28
30
|
rate_limit: RateLimitMode = RateLimitMode.MID
|
|
29
31
|
rate_limit_default: int | None = None # overrides ratelimit mode # rate limit for standard actions
|
|
30
32
|
rate_limit_actions: dict[str, float | int] = field(default_factory=lambda: {}) # overrides ratelimit mode # custom rate limits for specific actions (eg. {'add_comment': 10})
|
|
33
|
+
|
|
31
34
|
# is_logging_enabled: bool = True # TODO
|
|
32
35
|
# logging_level = 'DEBUG'
|
|
36
|
+
|
|
33
37
|
is_default: bool = False
|
|
38
|
+
|
|
34
39
|
userposts_add_pinned_post: bool = True
|
|
40
|
+
|
|
35
41
|
auto_load: bool = True
|
|
36
42
|
load_on_getitem: int | All | Batch | None = 1
|
|
37
43
|
load_on_iter: int | All | Batch | None = BATCH
|
|
38
44
|
force_load_lists: bool = False # load lists even if has_more is False
|
|
45
|
+
|
|
39
46
|
debug_response: DebugResponseMode = DebugResponseMode.NO
|
|
47
|
+
|
|
40
48
|
timeout: float = 30
|
|
41
49
|
timeout_file: float = 120
|
|
50
|
+
|
|
42
51
|
url: str = 'xn--d1ah4a.com'
|
|
43
52
|
url_api: str | None = None
|
|
44
53
|
user_agent: UserAgent | str = UserAgent.BROWSER
|
|
45
54
|
solve_challenge: bool = True
|
|
55
|
+
|
|
46
56
|
load_comments_from_post: bool = False
|
|
57
|
+
|
|
47
58
|
parse_mode: ParseMode = ParseMode.NO
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
|
|
60
|
+
rate_limit_wait: int | None = None # DEPRECATED
|
|
61
|
+
retry_on_rate_limits: bool | None = None # DEPRECATED
|
|
62
|
+
retry_enabled: bool = True
|
|
63
|
+
retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
|
|
64
|
+
retry_max_retries: int | None = None # none for no limit
|
|
65
|
+
retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
|
|
66
|
+
|
|
67
|
+
bypass_auth_level: bool = False
|
|
68
|
+
|
|
69
|
+
dwell_enabled: bool = True
|
|
70
|
+
dwell_max_buffer: int = 20
|
|
71
|
+
dwell_send_interval: float = 2
|
|
72
|
+
dwell_save_on_quit: bool = True
|
|
50
73
|
|
|
51
74
|
def __post_init__(self):
|
|
52
75
|
if self.rate_limit_default:
|
|
@@ -73,10 +96,19 @@ class Config:
|
|
|
73
96
|
case _:
|
|
74
97
|
self._user_agent = self.user_agent
|
|
75
98
|
|
|
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
|
+
|
|
106
|
+
self._retry_exceptions = (tuple(self.retry_exceptions) if isinstance(self.retry_exceptions, list) else self.retry_exceptions) or (RateLimitError, InternalError, RequestException)
|
|
76
107
|
|
|
77
108
|
|
|
78
109
|
|
|
79
110
|
class Client:
|
|
111
|
+
auth_level: AuthLevel = AuthLevel.NO
|
|
80
112
|
access_token: str | None = None
|
|
81
113
|
refresh_token: str | None = None
|
|
82
114
|
_user = None
|
|
@@ -92,18 +124,26 @@ class Client:
|
|
|
92
124
|
self.session.mount('https://', adapter)
|
|
93
125
|
|
|
94
126
|
if access:
|
|
127
|
+
self.auth_level = AuthLevel.ACCESS
|
|
95
128
|
self.access_token = access.replace('Bearer ', '')
|
|
96
129
|
|
|
97
130
|
if refresh:
|
|
131
|
+
self.auth_level = AuthLevel.REFRESH
|
|
98
132
|
self.refresh_token = refresh
|
|
99
133
|
self.session.cookies.set('refresh_token', refresh, path='/', domain=self.config.url)
|
|
100
|
-
if access is None:
|
|
101
|
-
|
|
134
|
+
# if access is None:
|
|
135
|
+
# self.refresh_auth()
|
|
102
136
|
|
|
103
137
|
if _default_client is None or config.is_default:
|
|
104
138
|
set_default_client(self)
|
|
105
139
|
|
|
106
|
-
|
|
140
|
+
if self.auth_level > AuthLevel.NO and self.config.dwell_enabled:
|
|
141
|
+
self.dwell_tracker = DwellTracker(self)
|
|
142
|
+
self.dwell_tracker._start_timer()
|
|
143
|
+
else:
|
|
144
|
+
self.dwell_tracker = None
|
|
145
|
+
|
|
146
|
+
def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
|
|
107
147
|
"""Сделать запрос
|
|
108
148
|
|
|
109
149
|
Args:
|
|
@@ -112,11 +152,18 @@ class Client:
|
|
|
112
152
|
params (dict, optional): Параметры. Defaults to {}.
|
|
113
153
|
files (dict[str, tuple[str, BufferedReader | bytes]], optional): Файлы. Defaults to {}.
|
|
114
154
|
"""
|
|
115
|
-
l.debug('%s %s params=%s', method.upper(), url, params)
|
|
155
|
+
l.debug('%s %s params=%s authlevel=%s', method.upper(), url, params, level.value)
|
|
156
|
+
|
|
157
|
+
if level > self.auth_level and not self.config.bypass_auth_level:
|
|
158
|
+
raise InsufficientAuthLevelError(self.auth_level, level)
|
|
159
|
+
|
|
160
|
+
if level >= AuthLevel.ACCESS and self.access_token is None and url != 'v1/auth/refresh':
|
|
161
|
+
self.refresh_auth()
|
|
162
|
+
|
|
116
163
|
def _fetch():
|
|
117
164
|
return fetch(self, method, url, params, files)
|
|
118
165
|
|
|
119
|
-
if not self.refresh_token:
|
|
166
|
+
if not self.refresh_token or url == 'v1/auth/refresh':
|
|
120
167
|
return _fetch()
|
|
121
168
|
|
|
122
169
|
try:
|
|
@@ -132,8 +179,6 @@ class Client:
|
|
|
132
179
|
str: Токен
|
|
133
180
|
"""
|
|
134
181
|
l.debug('refresh token')
|
|
135
|
-
if not self.refresh_token:
|
|
136
|
-
raise InsufficientAuthLevelError()
|
|
137
182
|
|
|
138
183
|
res = refresh_token(self)
|
|
139
184
|
res.raise_for_status()
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
1
3
|
from uuid import UUID
|
|
2
4
|
from datetime import datetime
|
|
3
5
|
from math import ceil
|
|
@@ -5,13 +7,14 @@ from math import ceil
|
|
|
5
7
|
from pydantic import Field, BaseModel, field_validator
|
|
6
8
|
|
|
7
9
|
from itd.base import ITDBaseModel, ITDList
|
|
8
|
-
from itd.client import Client
|
|
9
10
|
from itd.enums import CommentSorting, ReportTargetType, ReportReason
|
|
10
11
|
from itd.report import Report
|
|
11
12
|
from itd.utils import parse_datetime, to_nullable_uuid, format_attachments, ATTACHMENTS
|
|
12
13
|
from itd.api.comments import get_comments, add_comment, add_reply_comment, get_replies, like_comment, unlike_comment, delete_comment
|
|
13
14
|
from itd.user import User
|
|
14
15
|
from itd.file import CommentAttach
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from itd.client import Client
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
class Comment(ITDBaseModel):
|
|
@@ -130,6 +133,11 @@ class Comment(ITDBaseModel):
|
|
|
130
133
|
|
|
131
134
|
|
|
132
135
|
class _CommentValidate(BaseModel, Comment):
|
|
136
|
+
@field_validator('attachments', mode='plain')
|
|
137
|
+
@classmethod
|
|
138
|
+
def validate_attachments(cls, attachments: list[dict]):
|
|
139
|
+
return [CommentAttach(attach) for attach in attachments]
|
|
140
|
+
|
|
133
141
|
@field_validator('replies', mode='plain')
|
|
134
142
|
@classmethod
|
|
135
143
|
def validate_replies(cls, replies: list[dict]):
|
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
2
|
from typing import Literal
|
|
3
3
|
|
|
4
|
+
class InteractionType(Enum):
|
|
5
|
+
PHOTO_OPEN = 1
|
|
6
|
+
VIDEO_PROGRESS = 2
|
|
7
|
+
|
|
8
|
+
class ViewSource(Enum):
|
|
9
|
+
FEED_GLOBAL = 1
|
|
10
|
+
FEED_FOLLOWING = 2
|
|
11
|
+
FEED_CLAN = 3
|
|
12
|
+
PROFILE = 4
|
|
13
|
+
HASHTAG = 5
|
|
14
|
+
POST_PAGE = 6
|
|
15
|
+
LINK = 7
|
|
16
|
+
SEARCH = 8
|
|
17
|
+
|
|
18
|
+
class ViewReason(Enum):
|
|
19
|
+
NORMAL = 0 # стандартное
|
|
20
|
+
BLUR = 1 # страница скрыта (listener blur)
|
|
21
|
+
HIDDEN = 2 # тоже страница скрыта (listener visibilitychange и доп.проверка что document.hidden)
|
|
22
|
+
PAGE_HIDE = 3 # и это тоже страница скрыта (listener pagehiden)
|
|
23
|
+
UNOBSERVE = 4 # что то заумное на фронтедерском
|
|
24
|
+
THRESHOLD_MET = 5 # трэшхолд типо плавный просмотр, я не осбо понял для чего это
|
|
25
|
+
|
|
26
|
+
class AuthLevel(Enum):
|
|
27
|
+
NO = 'no'
|
|
28
|
+
ACCESS = 'access'
|
|
29
|
+
REFRESH = 'refresh'
|
|
30
|
+
# LOGIN = 'login' # 🤫
|
|
31
|
+
|
|
32
|
+
def __gt__(self, other):
|
|
33
|
+
return _AUTH_LEVEL_HIERARCHY.index(self) > _AUTH_LEVEL_HIERARCHY.index(other)
|
|
34
|
+
|
|
35
|
+
def __lt__(self, other):
|
|
36
|
+
return _AUTH_LEVEL_HIERARCHY.index(self) < _AUTH_LEVEL_HIERARCHY.index(other)
|
|
37
|
+
|
|
38
|
+
def __ge__(self, other):
|
|
39
|
+
return _AUTH_LEVEL_HIERARCHY.index(self) >= _AUTH_LEVEL_HIERARCHY.index(other)
|
|
40
|
+
|
|
41
|
+
def __le__(self, other):
|
|
42
|
+
return _AUTH_LEVEL_HIERARCHY.index(self) <= _AUTH_LEVEL_HIERARCHY.index(other)
|
|
43
|
+
|
|
44
|
+
_AUTH_LEVEL_HIERARCHY = [AuthLevel.NO, AuthLevel.ACCESS, AuthLevel.REFRESH]
|
|
45
|
+
|
|
4
46
|
class RateLimitMode(Enum):
|
|
5
47
|
NO = 'no'
|
|
6
48
|
MIN = 'min' # for one-time actions (eg script just to like post)
|
|
@@ -57,7 +99,7 @@ class AttachType(Enum):
|
|
|
57
99
|
AUDIO = 'audio'
|
|
58
100
|
IMAGE = 'image'
|
|
59
101
|
VIDEO = 'video'
|
|
60
|
-
|
|
102
|
+
MEDIA = 'media'
|
|
61
103
|
|
|
62
104
|
class PostsTab(Enum):
|
|
63
105
|
FOLLOWING = 'following'
|
|
@@ -81,18 +123,18 @@ class AccessType(Enum):
|
|
|
81
123
|
EVERYONE = 'everyone' # все
|
|
82
124
|
|
|
83
125
|
def __gt__(self, other):
|
|
84
|
-
return
|
|
126
|
+
return _ACCESS_TYPE_HIERARCHY.index(self) > _ACCESS_TYPE_HIERARCHY.index(other)
|
|
85
127
|
|
|
86
128
|
def __lt__(self, other):
|
|
87
|
-
return
|
|
129
|
+
return _ACCESS_TYPE_HIERARCHY.index(self) < _ACCESS_TYPE_HIERARCHY.index(other)
|
|
88
130
|
|
|
89
131
|
def __ge__(self, other):
|
|
90
|
-
return
|
|
132
|
+
return _ACCESS_TYPE_HIERARCHY.index(self) >= _ACCESS_TYPE_HIERARCHY.index(other)
|
|
91
133
|
|
|
92
134
|
def __le__(self, other):
|
|
93
|
-
return
|
|
135
|
+
return _ACCESS_TYPE_HIERARCHY.index(self) <= _ACCESS_TYPE_HIERARCHY.index(other)
|
|
94
136
|
|
|
95
|
-
|
|
137
|
+
_ACCESS_TYPE_HIERARCHY = [AccessType.EVERYONE, AccessType.FOLLOWERS, AccessType.MUTUAL, AccessType.EVERYONE] # 100% that hierarcy is spelled wrong
|
|
96
138
|
|
|
97
139
|
|
|
98
140
|
class SpanType(Enum):
|
|
@@ -2,6 +2,9 @@ from typing import Callable
|
|
|
2
2
|
|
|
3
3
|
from requests import Response
|
|
4
4
|
|
|
5
|
+
from itd.enums import AuthLevel
|
|
6
|
+
|
|
7
|
+
|
|
5
8
|
class ITDException(Exception):
|
|
6
9
|
code: str | None = None # ['error']['code']
|
|
7
10
|
message: str | None = None # ['error']['message']
|
|
@@ -92,6 +95,21 @@ class AccessTokenExpiredError(AuthError):
|
|
|
92
95
|
json_check = staticmethod(lambda json: json.get('error') == 'token expired')
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
class LoginError(AuthError): pass
|
|
99
|
+
|
|
100
|
+
class InvalidCredentials(LoginError):
|
|
101
|
+
code = 'ACCOUNT_INVALID_CREDENTIALS'
|
|
102
|
+
text = 'Invalid login credentials'
|
|
103
|
+
|
|
104
|
+
class CaptchaFailedError(LoginError):
|
|
105
|
+
code = 'TURNSTILE_VERIFICATION_FAILED'
|
|
106
|
+
text = 'Captcha check failed'
|
|
107
|
+
|
|
108
|
+
class EmailDomainNotAllowed(LoginError):
|
|
109
|
+
code = 'ACCOUNT_EMAIL_DOMAIN_NOT_ALLOWED'
|
|
110
|
+
text = 'Email domain not allowed'
|
|
111
|
+
|
|
112
|
+
|
|
95
113
|
class PasswordError(ITDException): pass
|
|
96
114
|
|
|
97
115
|
class SamePasswordError(PasswordError, ValidateError):
|
|
@@ -110,8 +128,8 @@ class InvalidPasswordError(PasswordError, ValidateError):
|
|
|
110
128
|
class NoRightsError(ITDException): pass
|
|
111
129
|
|
|
112
130
|
class InsufficientAuthLevelError(NoRightsError):
|
|
113
|
-
def __init__(self) :
|
|
114
|
-
self.text = 'Insufficient auth level'
|
|
131
|
+
def __init__(self, current: AuthLevel, requires: AuthLevel) :
|
|
132
|
+
self.text = f'Insufficient auth level (current is {current.name}, requires {requires.name})'
|
|
115
133
|
|
|
116
134
|
class PinNotOwnedError(NoRightsError):
|
|
117
135
|
code = "PIN_NOT_OWNED"
|
|
@@ -244,13 +262,21 @@ class TargetUserBannedError(ITDException): # target banned (eg if you try to fol
|
|
|
244
262
|
message = 'Этот аккаунт заблокирован'
|
|
245
263
|
text = 'Target user has been deactivated'
|
|
246
264
|
|
|
247
|
-
class AccountBannedError(
|
|
248
|
-
|
|
265
|
+
class AccountBannedError(LoginError): # you are banned
|
|
266
|
+
json_check = staticmethod(lambda json: json.get('error', {}).get('code') in ('ACCOUNT_BANNED', 'ACCOUNT_DEACTIVATED'))
|
|
249
267
|
text = 'Account has been deactivated'
|
|
250
268
|
|
|
269
|
+
class AccountTemporarilyBannedError(LoginError):
|
|
270
|
+
code = 'ACCOUNT_DEACTIVATED'
|
|
271
|
+
text = 'Account has been temporarily deactivated'
|
|
272
|
+
|
|
251
273
|
class ProfileRequiredError(ITDException):
|
|
252
274
|
code = 'PROFILE_REQUIRED'
|
|
253
275
|
text = 'No profile. Please create your profile first'
|
|
254
276
|
|
|
255
277
|
|
|
256
|
-
DEFAULT_ERRORS = (
|
|
278
|
+
DEFAULT_ERRORS = (
|
|
279
|
+
RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(),
|
|
280
|
+
AccountBannedError(), AccountTemporarilyBannedError(), InternalError(),
|
|
281
|
+
ProfileRequiredError(), RefreshTokenMissingError()
|
|
282
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
from os.path import basename
|
|
5
|
+
from _io import BufferedReader
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
from requests import get
|
|
10
|
+
|
|
11
|
+
from itd.base import ITDBaseModel, refresh_wrapper
|
|
12
|
+
from itd.enums import AttachType
|
|
13
|
+
from itd.api.files import upload_file, delete_file
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from itd.client import Client
|
|
16
|
+
from itd.post import Post
|
|
17
|
+
|
|
18
|
+
class File(ITDBaseModel):
|
|
19
|
+
_refreshable = False
|
|
20
|
+
_validator = lambda _: _FileValidate
|
|
21
|
+
|
|
22
|
+
id: UUID
|
|
23
|
+
url: str
|
|
24
|
+
filename: str
|
|
25
|
+
mime_type: str = Field(alias='mimeType')
|
|
26
|
+
size: int
|
|
27
|
+
# created_at: datetime | None = Field(None, alias='createdAt')
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: str, data: bytes | BufferedReader, client: Client | None = None):
|
|
30
|
+
super().__init__(client)
|
|
31
|
+
self.filename = name
|
|
32
|
+
self._upload(data)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_path(cls, path: Path | str):
|
|
36
|
+
if isinstance(path, str):
|
|
37
|
+
path = Path(path)
|
|
38
|
+
return cls(path.name, path.read_bytes())
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_bytes(cls, data: bytes | BufferedReader):
|
|
42
|
+
try:
|
|
43
|
+
from filetype import guess
|
|
44
|
+
except ModuleNotFoundError:
|
|
45
|
+
raise ImportError('filetype is required for File.from_bytes. Install by running "uv add itd-sdk[filetype]" (or "pip install itd-sdk[filetype]" if you are using pip)')
|
|
46
|
+
|
|
47
|
+
kind = guess(data)
|
|
48
|
+
return cls(
|
|
49
|
+
f'file.{kind.extension}' if kind else 'file.0',
|
|
50
|
+
data
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@refresh_wrapper
|
|
54
|
+
def _upload(self, data: bytes | BufferedReader):
|
|
55
|
+
return upload_file(self.client, self.filename, data).json()
|
|
56
|
+
|
|
57
|
+
def delete(self) -> None:
|
|
58
|
+
delete_file(self.client, self.id)
|
|
59
|
+
|
|
60
|
+
def download(self, name: str | None = None) -> None:
|
|
61
|
+
with open(name or self.filename, 'wb') as fl:
|
|
62
|
+
fl.write(get(self.url, timeout=60).content)
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return self.filename
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _FileValidate(BaseModel, File):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PostAttach(ITDBaseModel):
|
|
74
|
+
_validator = lambda _: _PostAttachValidate
|
|
75
|
+
_post: Post
|
|
76
|
+
|
|
77
|
+
id: UUID
|
|
78
|
+
type: AttachType = AttachType.IMAGE
|
|
79
|
+
url: str
|
|
80
|
+
thumbnail_url: str | None = Field(None, alias='thumbnailUrl')
|
|
81
|
+
width: int | None = None
|
|
82
|
+
height: int | None = None
|
|
83
|
+
|
|
84
|
+
def __init__(self, attach: dict, client: Client | None = None) -> None:
|
|
85
|
+
super().__init__(client)
|
|
86
|
+
|
|
87
|
+
for name, value in _PostAttachValidate.model_validate(attach).__dict__.items():
|
|
88
|
+
setattr(self, name, value)
|
|
89
|
+
|
|
90
|
+
def download(self, name: str) -> None:
|
|
91
|
+
"""Скачать вложение
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
name (str): Имя файла.
|
|
95
|
+
"""
|
|
96
|
+
with open(name, 'wb') as fl:
|
|
97
|
+
fl.write(get(self.url, timeout=self.client.config.timeout_file).content)
|
|
98
|
+
|
|
99
|
+
def record_open(self, client: Client | None = None):
|
|
100
|
+
"""Записать событие открытия фото
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
client (Client | None, optional): Клиент. Defaults to None.
|
|
104
|
+
"""
|
|
105
|
+
c = (client or self.client)
|
|
106
|
+
assert c.dwell_tracker is not None, 'Enable dwell to record photo opens'
|
|
107
|
+
assert self.type == AttachType.IMAGE, 'Recording photo open allowed only for images'
|
|
108
|
+
c.dwell_tracker.record_photo_open(self._post.vs, self._post.source, self.id, self._post.attachments.index(self))
|
|
109
|
+
|
|
110
|
+
def record_progress(self, duration: int, played: int | None = None, client: Client | None = None):
|
|
111
|
+
"""Записать событие просмотра видео
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
duration (int): Общая длина видео (в сожалению итд не отдает, пишите сами)
|
|
115
|
+
played (int | None, optional): Сколько было просмотренно (мс). None - берется из duration. Defaults to None.
|
|
116
|
+
client (Client | None, optional): Клиент. Defaults to None.
|
|
117
|
+
"""
|
|
118
|
+
c = (client or self.client)
|
|
119
|
+
assert c.dwell_tracker is not None, 'Enable dwell to record photo opens'
|
|
120
|
+
assert self.type == AttachType.VIDEO, 'Recording video progress allowed only for videos'
|
|
121
|
+
c.dwell_tracker.record_video_progress(self._post.vs, self._post.source, self.id, played or duration, duration)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _PostAttachValidate(BaseModel, PostAttach):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CommentAttach(PostAttach):
|
|
130
|
+
_validator = lambda _: _CommentAttachValidate
|
|
131
|
+
filename: str
|
|
132
|
+
mime_type: str = Field(alias='mimeType')
|
|
133
|
+
size: int
|
|
134
|
+
duration: int | None = None
|
|
135
|
+
order: int = 0
|
|
136
|
+
|
|
137
|
+
def __init__(self, attach: dict, client: Client | None = None) -> None:
|
|
138
|
+
super(PostAttach, self).__init__(client)
|
|
139
|
+
|
|
140
|
+
for name, value in _CommentAttachValidate.model_validate(attach).__dict__.items():
|
|
141
|
+
setattr(self, name, value)
|
|
142
|
+
|
|
143
|
+
def record_open(self, client: Client | None = None):
|
|
144
|
+
raise AttributeError
|
|
145
|
+
|
|
146
|
+
def record_progress(self, duration: int, played: int | None = None, client: Client | None = None):
|
|
147
|
+
raise AttributeError
|
|
148
|
+
|
|
149
|
+
def download(self, name: str | None = None) -> None:
|
|
150
|
+
"""Скачать вложение
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
name (str | None, optional): Имя файла. None - имя, под которым был загружен этот файл. Defaults to None.
|
|
154
|
+
"""
|
|
155
|
+
with open(name or self.filename, 'wb') as fl:
|
|
156
|
+
fl.write(get(self.url, timeout=self.client.config.timeout_file).content)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _CommentAttachValidate(BaseModel, CommentAttach):
|
|
160
|
+
pass
|
|
@@ -1,30 +1,210 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from uuid import UUID, uuid4
|
|
3
|
+
from datetime import datetime, timedelta
|
|
3
4
|
from typing import Literal, overload, TYPE_CHECKING
|
|
4
5
|
from time import sleep
|
|
6
|
+
from threading import Thread
|
|
7
|
+
from atexit import register
|
|
5
8
|
|
|
6
|
-
from pydantic import Field, BaseModel, field_validator
|
|
9
|
+
from pydantic import Field, BaseModel, field_validator, field_serializer
|
|
7
10
|
|
|
8
|
-
from itd.base import ITDBaseModel, refresh_wrapper, ITDList
|
|
9
|
-
from itd.client import Client
|
|
10
11
|
from itd.comment import Comment, Comments
|
|
11
|
-
from itd.enums import PostsTab, UserPostSorting, ReportReason, ReportTargetType, ParseMode, ALL
|
|
12
12
|
from itd.file import PostAttach
|
|
13
13
|
from itd.hashtag import Hashtag
|
|
14
14
|
from itd.poll import Poll, NewPoll, PollOption
|
|
15
15
|
from itd.report import Report
|
|
16
16
|
from itd.span import Span
|
|
17
17
|
from itd.user import User, _UserBase, Me
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
from itd.api.posts import (
|
|
20
20
|
get_post, create_post, like_post, unlike_post, repost, view_post, pin_post, unpin_post,
|
|
21
21
|
delete_post, restore_post, edit_post, get_posts, get_user_posts, get_liked_posts
|
|
22
22
|
)
|
|
23
23
|
from itd.api.hashtags import get_posts_by_hashtag
|
|
24
|
+
from itd.api.dwell import send_views, send_interactions
|
|
25
|
+
from itd.base import ITDBaseModel, refresh_wrapper, ITDList
|
|
26
|
+
from itd.enums import PostsTab, UserPostSorting, ReportReason, ReportTargetType, ParseMode, ALL, ViewReason, ViewSource, InteractionType
|
|
24
27
|
from itd.logger import get_logger
|
|
28
|
+
from itd.utils import to_uuid, parse_datetime, format_attachments, ATTACHMENTS, parse_html, parse_md
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from itd.client import Client
|
|
25
31
|
|
|
26
32
|
l = get_logger('post')
|
|
27
33
|
|
|
34
|
+
class DwellEvent(BaseModel):
|
|
35
|
+
vs: str = Field(alias='v')
|
|
36
|
+
source: ViewSource = Field(alias='s')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class InteractionEvent(DwellEvent):
|
|
40
|
+
type: InteractionType = Field(alias='t')
|
|
41
|
+
attachment_id: UUID = Field(alias='ai')
|
|
42
|
+
|
|
43
|
+
class PhotoOpenEvent(InteractionEvent):
|
|
44
|
+
index: int | None = Field(None, alias='mi')
|
|
45
|
+
|
|
46
|
+
class VideoProgressEvent(InteractionEvent):
|
|
47
|
+
played: int = Field(alias='pm')
|
|
48
|
+
duration: int = Field(alias='dm')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ViewEvent(DwellEvent):
|
|
52
|
+
duration: int = Field(alias='md')
|
|
53
|
+
entered_at: int = Field(alias='et')
|
|
54
|
+
exited_at: int = Field(alias='xt')
|
|
55
|
+
reason: ViewReason = Field(alias='r')
|
|
56
|
+
source_context: str | None = Field(None, alias='sc')
|
|
57
|
+
has_seen: bool = Field(False, alias='b')
|
|
58
|
+
|
|
59
|
+
@field_serializer('has_seen', mode='plain')
|
|
60
|
+
@classmethod
|
|
61
|
+
def serialize_has_seen(cls, value: bool):
|
|
62
|
+
return int(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DwellTracker(ITDBaseModel):
|
|
66
|
+
def __init__(self, client: Client | None = None) -> None:
|
|
67
|
+
super().__init__(client)
|
|
68
|
+
self.views: list[ViewEvent] = []
|
|
69
|
+
self.interactions: list[InteractionEvent] = []
|
|
70
|
+
self.seen_posts: set[UUID] = set()
|
|
71
|
+
self.sid = uuid4()
|
|
72
|
+
self._thread: Thread | None = None
|
|
73
|
+
|
|
74
|
+
def send_views(self) -> bool: # call on app visibilitychange
|
|
75
|
+
"""Отправить просмотры (api/v1/i) и очистить буффер
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
bool: Статус (False если буффер пустой и ничего не было отправлено)
|
|
79
|
+
"""
|
|
80
|
+
if not self.views:
|
|
81
|
+
return False
|
|
82
|
+
l.info('dwell send view batch')
|
|
83
|
+
send_views(self.client, [event.model_dump(mode='json', by_alias=True) for event in self.views], self.sid)
|
|
84
|
+
self.views.clear()
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def send_interactions(self) -> bool: # call on app visibilitychange
|
|
88
|
+
"""Отправить события взаимодействий с вложениями (api/v1/x) и очистить буффер
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
bool: Статус (False если буффер пустой и ничего не было отправлено)
|
|
92
|
+
"""
|
|
93
|
+
if not self.interactions:
|
|
94
|
+
return False
|
|
95
|
+
l.info('dwell send interactions batch')
|
|
96
|
+
send_interactions(self.client, [event.model_dump(mode='json', by_alias=True) for event in self.interactions], self.sid)
|
|
97
|
+
self.interactions.clear()
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def record_view(self, id: UUID, vs: str, duration: int, entered_at: datetime, source: ViewSource, source_context: str | None = None, reason: ViewReason = ViewReason.NORMAL):
|
|
101
|
+
"""Записать событие просмотра
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
id (UUID): ID поста
|
|
105
|
+
vs (str): VS
|
|
106
|
+
duration (int): Время на просмотр (сколько времени пользователь читал пост) (мс). Желательно должно быть 250+
|
|
107
|
+
entered_at (datetime): Дата открытия поста (когда пользователь увидел пост)
|
|
108
|
+
reason (ViewReason): Причина просмотра
|
|
109
|
+
source (ViewSource): Страница, с которой произошел просмотр
|
|
110
|
+
"""
|
|
111
|
+
l.info(
|
|
112
|
+
'dwell add view record id=%s vs=%s duration=%s entered_at=%s exited_at=%s source=%s source_context=%s reason=%s',
|
|
113
|
+
id, vs, duration, entered_at.strftime('%m.%d %H:%M:%S'),
|
|
114
|
+
(entered_at + timedelta(milliseconds=duration)).strftime('%m.%d %H:%M:%S'),
|
|
115
|
+
source.value, source_context, reason.value
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self.views.append(
|
|
119
|
+
ViewEvent( # stupid pydantic i want validate by name
|
|
120
|
+
v=vs,
|
|
121
|
+
md=duration,
|
|
122
|
+
et=round(entered_at.timestamp() * 1000),
|
|
123
|
+
xt=round(entered_at.timestamp() * 1000) + duration,
|
|
124
|
+
r=reason,
|
|
125
|
+
s=source,
|
|
126
|
+
sc=source_context,
|
|
127
|
+
b=id in self.seen_posts
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
self.seen_posts.add(id)
|
|
131
|
+
if len(self.views) >= self.client.config.dwell_max_buffer:
|
|
132
|
+
self.send_views()
|
|
133
|
+
|
|
134
|
+
def record_photo_open(self, vs: str, source: ViewSource, attachment_id: UUID, index: int):
|
|
135
|
+
"""Записать событие просомтра фото
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
vs (str): VS
|
|
139
|
+
source (ViewSource): Страница, с которой проищошел просмотр
|
|
140
|
+
attachment_id (UUID): ID вложения
|
|
141
|
+
index (int): Индекс вложения
|
|
142
|
+
"""
|
|
143
|
+
l.info('dwell add photo open record vs=%s source=%s id=%s index=%s', vs, source.value, attachment_id, index)
|
|
144
|
+
|
|
145
|
+
self.interactions.append(
|
|
146
|
+
PhotoOpenEvent(
|
|
147
|
+
v=vs,
|
|
148
|
+
s=source,
|
|
149
|
+
t=InteractionType.PHOTO_OPEN,
|
|
150
|
+
ai=attachment_id,
|
|
151
|
+
mi=index
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
if len(self.interactions) >= self.client.config.dwell_max_buffer:
|
|
155
|
+
self.send_interactions()
|
|
156
|
+
|
|
157
|
+
def record_video_progress(self, vs: str, source: ViewSource, attachment_id: UUID, played: int, duration: int):
|
|
158
|
+
"""Записать событие просмотра видео (отправлять каждые 2-3 сек пока запущено видео)
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
vs (str): VS
|
|
162
|
+
source (ViewSource): Страница, с которой произошел просмотр
|
|
163
|
+
attachment_id (UUID): ID просмотренного вложения
|
|
164
|
+
played (int): Сколько было просмотренно (мс) с учетом перепросмотров
|
|
165
|
+
duration (int): Общая длительность видео (константа) (мс)
|
|
166
|
+
"""
|
|
167
|
+
l.info('dwell add video progress record vs=%s source=%s id=%s played=%s duration=%s', vs, source, attachment_id, played, duration)
|
|
168
|
+
|
|
169
|
+
self.interactions.append(
|
|
170
|
+
VideoProgressEvent(
|
|
171
|
+
v=vs,
|
|
172
|
+
s=source,
|
|
173
|
+
t=InteractionType.VIDEO_PROGRESS,
|
|
174
|
+
ai=attachment_id,
|
|
175
|
+
pm=played,
|
|
176
|
+
dm=duration
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
if len(self.interactions) >= self.client.config.dwell_max_buffer:
|
|
180
|
+
self.send_interactions()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _start_timer(self):
|
|
184
|
+
if not self.client.config.dwell_send_interval:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
def loop():
|
|
188
|
+
while True:
|
|
189
|
+
sleep(self.client.config.dwell_send_interval)
|
|
190
|
+
self.send_views()
|
|
191
|
+
self.send_interactions()
|
|
192
|
+
|
|
193
|
+
self._thread = Thread(target=loop)
|
|
194
|
+
self._thread.daemon = True
|
|
195
|
+
self._thread.start()
|
|
196
|
+
|
|
197
|
+
def on_exit():
|
|
198
|
+
l.debug('stop dwell timer')
|
|
199
|
+
if self._thread:
|
|
200
|
+
self._thread.join(timeout=0)
|
|
201
|
+
self.send_views()
|
|
202
|
+
self.send_interactions()
|
|
203
|
+
|
|
204
|
+
if self.client.config.dwell_save_on_quit:
|
|
205
|
+
register(on_exit)
|
|
206
|
+
|
|
207
|
+
|
|
28
208
|
|
|
29
209
|
class Post(ITDBaseModel):
|
|
30
210
|
_validator = lambda _: _PostValidate
|
|
@@ -58,15 +238,26 @@ class Post(ITDBaseModel):
|
|
|
58
238
|
|
|
59
239
|
wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId')
|
|
60
240
|
wall_recipient: User | None = Field(None, alias='wallRecipient')
|
|
241
|
+
# vs: ViewerSession
|
|
242
|
+
vs: str = Field('') # from 13.05 it is string token
|
|
61
243
|
|
|
62
244
|
|
|
63
|
-
def __init__(self, id: str | UUID, client: Client | None = None) -> None:
|
|
245
|
+
def __init__(self, id: str | UUID, source: ViewSource = ViewSource.POST_PAGE, source_context: str | None = None, client: Client | None = None) -> None:
|
|
64
246
|
self.id = to_uuid(id)
|
|
247
|
+
self.source = source
|
|
248
|
+
self.source_context = source_context
|
|
249
|
+
|
|
65
250
|
super().__init__(client)
|
|
66
251
|
|
|
67
252
|
def for_client(self, client: Client):
|
|
68
253
|
return Post(self.id, client=client)
|
|
69
254
|
|
|
255
|
+
def _post_refresh(self):
|
|
256
|
+
self.comments = Comments()
|
|
257
|
+
self.comments._post_id = self.id
|
|
258
|
+
for attachment in self.attachments:
|
|
259
|
+
attachment._post = self
|
|
260
|
+
|
|
70
261
|
|
|
71
262
|
@classmethod
|
|
72
263
|
def new(
|
|
@@ -117,12 +308,13 @@ class Post(ITDBaseModel):
|
|
|
117
308
|
for name, value in validated.__dict__.items():
|
|
118
309
|
setattr(instance, name, value)
|
|
119
310
|
|
|
120
|
-
instance._loaded =
|
|
311
|
+
instance._loaded = False
|
|
312
|
+
instance._post_refresh()
|
|
121
313
|
|
|
122
314
|
return instance
|
|
123
315
|
|
|
124
316
|
@classmethod
|
|
125
|
-
def _from_dict(cls, data: dict, set_loaded: bool = True, client: Client | None = None) -> 'Post':
|
|
317
|
+
def _from_dict(cls, data: dict, source: ViewSource = ViewSource.POST_PAGE, source_context: str | None = None, set_loaded: bool = True, client: Client | None = None) -> 'Post':
|
|
126
318
|
instance = cls.__new__(cls)
|
|
127
319
|
super(Post, instance).__init__(client)
|
|
128
320
|
|
|
@@ -132,6 +324,9 @@ class Post(ITDBaseModel):
|
|
|
132
324
|
setattr(instance, name, value)
|
|
133
325
|
|
|
134
326
|
instance._loaded = set_loaded
|
|
327
|
+
instance.source = source
|
|
328
|
+
instance.source_context = source_context
|
|
329
|
+
instance._post_refresh()
|
|
135
330
|
return instance
|
|
136
331
|
|
|
137
332
|
|
|
@@ -219,14 +414,21 @@ class Post(ITDBaseModel):
|
|
|
219
414
|
|
|
220
415
|
return Post._from_dict(post, client=client)
|
|
221
416
|
|
|
222
|
-
def view(self, client: Client | None = None) -> None:
|
|
417
|
+
def view(self, client: Client | None = None, entered_at: datetime | None = None, duration: int = 250, reason: ViewReason = ViewReason.NORMAL) -> None:
|
|
223
418
|
"""Просмотреть пост
|
|
224
419
|
|
|
225
420
|
Args:
|
|
226
421
|
client (Client | None, optional): Клиент. Defaults to None.
|
|
227
422
|
"""
|
|
228
|
-
|
|
229
|
-
if
|
|
423
|
+
c = client or self.client
|
|
424
|
+
if c.dwell_tracker is not None:
|
|
425
|
+
if self.vs is None:
|
|
426
|
+
self.refresh(c)
|
|
427
|
+
assert self.vs
|
|
428
|
+
c.dwell_tracker.record_view(self.id, self.vs, duration, entered_at or datetime.now() - timedelta(milliseconds=duration), self.source, self.source_context, reason)
|
|
429
|
+
else:
|
|
430
|
+
view_post(c, self.id)
|
|
431
|
+
if c == self.client:
|
|
230
432
|
self.is_viewed = True
|
|
231
433
|
# post can be already viewed, so view will not add; thats why do not change views_count
|
|
232
434
|
|
|
@@ -313,17 +515,14 @@ class Post(ITDBaseModel):
|
|
|
313
515
|
def url(self) -> str:
|
|
314
516
|
return f'https://xn--d1ah4a.com/@{self.author.username}/post/{self.id}'
|
|
315
517
|
|
|
316
|
-
if not TYPE_CHECKING:
|
|
317
|
-
def __getattribute__(self, name: str):
|
|
318
|
-
value = super().__getattribute__(name)
|
|
319
|
-
if name == 'comments' and getattr(value, '_post_id', None) is None:
|
|
320
|
-
value = Comments()
|
|
321
|
-
value._post_id = self.id
|
|
322
|
-
return value
|
|
323
|
-
|
|
324
518
|
|
|
325
519
|
|
|
326
520
|
class _PostValidate(BaseModel, Post): # BaseModel MUST be first or you ll have some problems with init
|
|
521
|
+
@field_validator('attachments', mode='plain')
|
|
522
|
+
@classmethod
|
|
523
|
+
def validate_attachments(cls, attachments: list[dict]):
|
|
524
|
+
return [PostAttach(attach) for attach in attachments]
|
|
525
|
+
|
|
327
526
|
@field_validator('edited_at', mode='plain')
|
|
328
527
|
@classmethod
|
|
329
528
|
def validate_edited_at(cls, v: str | None):
|
|
@@ -341,7 +540,7 @@ class _PostValidate(BaseModel, Post): # BaseModel MUST be first or you ll have s
|
|
|
341
540
|
def validate_original_post(cls, post: dict | None = None):
|
|
342
541
|
if post is None:
|
|
343
542
|
return
|
|
344
|
-
return Post._from_dict(post, False)
|
|
543
|
+
return Post._from_dict(post, set_loaded=False)
|
|
345
544
|
|
|
346
545
|
@field_validator('poll', mode='plain')
|
|
347
546
|
@classmethod
|
|
@@ -370,10 +569,17 @@ class _PostValidate(BaseModel, Post): # BaseModel MUST be first or you ll have s
|
|
|
370
569
|
if wall_recipient is not None:
|
|
371
570
|
return User._from_dict(wall_recipient, False)
|
|
372
571
|
|
|
572
|
+
# @field_validator('vs', mode='plain')
|
|
573
|
+
# @classmethod
|
|
574
|
+
# def validate_vs(cls, vs: str):
|
|
575
|
+
# return ViewerSession(decode_jwt_payload(vs))
|
|
576
|
+
|
|
373
577
|
|
|
374
578
|
|
|
375
579
|
class _BasePosts(ITDList[Post]):
|
|
376
580
|
_limit = 50
|
|
581
|
+
source: ViewSource
|
|
582
|
+
source_context: str | None = None
|
|
377
583
|
|
|
378
584
|
@staticmethod
|
|
379
585
|
def _get_cursor(data: dict):
|
|
@@ -388,7 +594,7 @@ class _BasePosts(ITDList[Post]):
|
|
|
388
594
|
return data['posts']
|
|
389
595
|
|
|
390
596
|
def _extend(self, objects: list, client: Client):
|
|
391
|
-
self.extend([Post._from_dict(post, client=client) for post in objects])
|
|
597
|
+
self.extend([Post._from_dict(post, self.source, self.source_context, client=client) for post in objects])
|
|
392
598
|
|
|
393
599
|
def __setattr__(self, name: str, value) -> None:
|
|
394
600
|
if name == '_client':
|
|
@@ -404,6 +610,13 @@ class Posts(_BasePosts):
|
|
|
404
610
|
def __init__(self, tab: PostsTab = PostsTab.POPULAR, client: Client | None = None) -> None:
|
|
405
611
|
super().__init__(client)
|
|
406
612
|
self.tab = tab
|
|
613
|
+
match tab:
|
|
614
|
+
case PostsTab.POPULAR:
|
|
615
|
+
self.source = ViewSource.FEED_GLOBAL
|
|
616
|
+
case PostsTab.FOLLOWING:
|
|
617
|
+
self.source = ViewSource.FEED_FOLLOWING
|
|
618
|
+
case PostsTab.CLAN:
|
|
619
|
+
self.source = ViewSource.FEED_CLAN
|
|
407
620
|
|
|
408
621
|
def _fetch(self, client: Client, limit: int) -> dict:
|
|
409
622
|
return get_posts(client, self.cursor, limit, self.tab).json()['data']
|
|
@@ -429,6 +642,7 @@ class UserPosts(_BasePosts):
|
|
|
429
642
|
_load_with_parent = False
|
|
430
643
|
cursor: datetime | None = None
|
|
431
644
|
_force_remove_pinned_post: bool = False
|
|
645
|
+
source = ViewSource.PROFILE
|
|
432
646
|
|
|
433
647
|
# ! not includes posts from other users (wall posts)
|
|
434
648
|
# def _get_total(self, data: dict):
|
|
@@ -446,6 +660,7 @@ class UserPosts(_BasePosts):
|
|
|
446
660
|
raise ValueError('User must be instance of User or Me class')
|
|
447
661
|
|
|
448
662
|
self.sorting = sorting # sort is busy
|
|
663
|
+
self.source_context = str(self.user.id)
|
|
449
664
|
|
|
450
665
|
def _fetch(self, client: Client, limit: int) -> dict:
|
|
451
666
|
if self.sorting == UserPostSorting.NEW and client.config.userposts_add_pinned_post and not self._force_remove_pinned_post:
|
|
@@ -507,6 +722,7 @@ class LikedPosts(_BasePosts): # [] if forbidden
|
|
|
507
722
|
class HashtagPosts(_BasePosts):
|
|
508
723
|
hashtag: Hashtag
|
|
509
724
|
cursor: UUID | None = None
|
|
725
|
+
source = ViewSource.HASHTAG
|
|
510
726
|
|
|
511
727
|
def __init__(self, hashtag: Hashtag | str, client: Client | None = None) -> None:
|
|
512
728
|
super().__init__(client)
|
|
@@ -514,12 +730,13 @@ class HashtagPosts(_BasePosts):
|
|
|
514
730
|
if isinstance(hashtag, str):
|
|
515
731
|
hashtag = Hashtag(hashtag, self.client)
|
|
516
732
|
self.hashtag = hashtag
|
|
733
|
+
self.source_context = self.hashtag.name
|
|
517
734
|
|
|
518
735
|
def _fetch(self, client: Client, limit: int) -> dict:
|
|
519
736
|
return get_posts_by_hashtag(client, self.hashtag.name, self.cursor, limit).json()['data']
|
|
520
737
|
|
|
521
738
|
def _extend(self, objects: list, client: Client):
|
|
522
|
-
self.extend([Post._from_dict(post, False, client=client) for post in objects])
|
|
739
|
+
self.extend([Post._from_dict(post, self.source, self.source_context, set_loaded=False, client=client) for post in objects])
|
|
523
740
|
|
|
524
741
|
def _get_total(self, data: dict):
|
|
525
742
|
return data['hashtag']['postsCount']
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build"]
|
|
2
|
+
requires = ["uv_build~=0.11"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[tool.uv.build-backend]
|
|
@@ -14,7 +14,7 @@ version_provider = "uv"
|
|
|
14
14
|
|
|
15
15
|
[project]
|
|
16
16
|
name = "itd-sdk"
|
|
17
|
-
version = "2.
|
|
17
|
+
version = "2.3.0"
|
|
18
18
|
description = "ITD client for python"
|
|
19
19
|
readme = "README.md"
|
|
20
20
|
authors = [
|
|
@@ -25,8 +25,7 @@ classifiers = [
|
|
|
25
25
|
'Natural Language :: Russian',
|
|
26
26
|
'Programming Language :: Python',
|
|
27
27
|
'Programming Language :: Python :: 3',
|
|
28
|
-
'Framework :: Pydantic :: 2'
|
|
29
|
-
'License :: OSI Approved :: MIT License'
|
|
28
|
+
'Framework :: Pydantic :: 2'
|
|
30
29
|
]
|
|
31
30
|
keywords = ['itd', 'itdsocial']
|
|
32
31
|
license = "MIT"
|
itd_sdk-2.1.2/itd/api/auth.py
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING
|
|
3
|
-
|
|
4
|
-
from requests import Response
|
|
5
|
-
|
|
6
|
-
from itd.base import catch_errors
|
|
7
|
-
from itd.request import fetch
|
|
8
|
-
from itd.exceptions import InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError, SessionExpiredError, SessionRevokedError
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from itd.client import Client
|
|
11
|
-
|
|
12
|
-
@catch_errors(SessionExpiredError(), SessionNotFoundError(), SessionRevokedError())
|
|
13
|
-
def refresh_token(client: Client) -> Response:
|
|
14
|
-
return fetch(client, 'post', 'v1/auth/refresh')
|
|
15
|
-
|
|
16
|
-
@catch_errors(InvalidPasswordError(), SamePasswordError(), InvalidOldPasswordError())
|
|
17
|
-
def change_password(client: Client, old: str, new: str) -> Response:
|
|
18
|
-
return client.request('post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old})
|
|
19
|
-
|
|
20
|
-
@catch_errors()
|
|
21
|
-
def logout(client: Client) -> Response:
|
|
22
|
-
return client.request('post', 'v1/auth/logout')
|
itd_sdk-2.1.2/itd/file.py
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING
|
|
3
|
-
from uuid import UUID
|
|
4
|
-
from os.path import basename
|
|
5
|
-
from _io import BufferedReader
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
9
|
-
from requests import get
|
|
10
|
-
|
|
11
|
-
from itd.base import ITDBaseModel, refresh_wrapper
|
|
12
|
-
from itd.enums import AttachType
|
|
13
|
-
from itd.api.files import upload_file, delete_file
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from itd.client import Client
|
|
16
|
-
|
|
17
|
-
class File(ITDBaseModel):
|
|
18
|
-
_refreshable = False
|
|
19
|
-
_validator = lambda _: _FileValidate
|
|
20
|
-
|
|
21
|
-
id: UUID
|
|
22
|
-
url: str
|
|
23
|
-
filename: str
|
|
24
|
-
mime_type: str = Field(alias='mimeType')
|
|
25
|
-
size: int
|
|
26
|
-
# created_at: datetime | None = Field(None, alias='createdAt')
|
|
27
|
-
|
|
28
|
-
def __init__(self, name: str, data: bytes | BufferedReader, client: Client | None = None):
|
|
29
|
-
super().__init__(client)
|
|
30
|
-
self.filename = name
|
|
31
|
-
self._upload(data)
|
|
32
|
-
|
|
33
|
-
@classmethod
|
|
34
|
-
def from_path(cls, path: Path | str):
|
|
35
|
-
if isinstance(path, str):
|
|
36
|
-
path = Path(path)
|
|
37
|
-
return cls(path.name, path.read_bytes())
|
|
38
|
-
|
|
39
|
-
@classmethod
|
|
40
|
-
def from_bytes(cls, data: bytes | BufferedReader):
|
|
41
|
-
try:
|
|
42
|
-
from filetype import guess
|
|
43
|
-
except ModuleNotFoundError:
|
|
44
|
-
raise ImportError('filetype is required for File.from_bytes. Install by running "uv add itd-sdk[filetype]" (or "pip install itd-sdk[filetype]" if you are using pip)')
|
|
45
|
-
|
|
46
|
-
kind = guess(data)
|
|
47
|
-
return cls(
|
|
48
|
-
f'file.{kind.extension}' if kind else 'file.0',
|
|
49
|
-
data
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
@refresh_wrapper
|
|
53
|
-
def _upload(self, data: bytes | BufferedReader):
|
|
54
|
-
return upload_file(self.client, self.filename, data).json()
|
|
55
|
-
|
|
56
|
-
def delete(self) -> None:
|
|
57
|
-
delete_file(self.client, self.id)
|
|
58
|
-
|
|
59
|
-
def download(self, name: str | None = None) -> None:
|
|
60
|
-
with open(name or self.filename, 'wb') as fl:
|
|
61
|
-
fl.write(get(self.url, timeout=60).content)
|
|
62
|
-
|
|
63
|
-
def __str__(self) -> str:
|
|
64
|
-
return self.filename
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class _FileValidate(BaseModel, File):
|
|
68
|
-
pass
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
class PostAttach(BaseModel):
|
|
73
|
-
id: UUID
|
|
74
|
-
type: AttachType = AttachType.IMAGE
|
|
75
|
-
url: str
|
|
76
|
-
thumbnail_url: str | None = Field(None, alias='thumbnailUrl')
|
|
77
|
-
width: int | None = None
|
|
78
|
-
height: int | None = None
|
|
79
|
-
|
|
80
|
-
def download(self, name: str) -> None:
|
|
81
|
-
with open(name, 'wb') as fl:
|
|
82
|
-
fl.write(get(self.url, timeout=60).content)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
class CommentAttach(PostAttach):
|
|
86
|
-
filename: str
|
|
87
|
-
mime_type: str = Field(alias='mimeType')
|
|
88
|
-
size: int
|
|
89
|
-
duration: int | None = None
|
|
90
|
-
order: int = 0
|
|
91
|
-
|
|
92
|
-
def download(self, name: str | None = None) -> None:
|
|
93
|
-
with open(name or self.filename, 'wb') as fl:
|
|
94
|
-
fl.write(get(self.url, timeout=60).content)
|
|
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
|