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.
Files changed (41) hide show
  1. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/PKG-INFO +1 -2
  2. itd_sdk-2.3.0/itd/api/auth.py +35 -0
  3. itd_sdk-2.3.0/itd/api/dwell.py +19 -0
  4. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/hashtags.py +3 -2
  5. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/search.py +2 -1
  6. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/base.py +10 -5
  7. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/client.py +56 -11
  8. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/comment.py +9 -1
  9. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/enums.py +48 -6
  10. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/exceptions.py +31 -5
  11. itd_sdk-2.3.0/itd/file.py +160 -0
  12. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/post.py +241 -24
  13. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/pyproject.toml +3 -4
  14. itd_sdk-2.1.2/itd/api/auth.py +0 -22
  15. itd_sdk-2.1.2/itd/file.py +0 -94
  16. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/README.md +0 -0
  17. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/__init__.py +0 -0
  18. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/_default.py +0 -0
  19. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/__init__.py +0 -0
  20. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/comments.py +0 -0
  21. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/etc.py +0 -0
  22. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/files.py +0 -0
  23. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/notifications.py +0 -0
  24. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/pins.py +0 -0
  25. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/polls.py +0 -0
  26. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/posts.py +0 -0
  27. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/reports.py +0 -0
  28. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/subscription.py +0 -0
  29. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/users.py +0 -0
  30. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/api/verification.py +0 -0
  31. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/clan.py +0 -0
  32. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/hashtag.py +0 -0
  33. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/logger.py +0 -0
  34. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/notification.py +0 -0
  35. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/pin.py +0 -0
  36. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/poll.py +0 -0
  37. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/report.py +0 -0
  38. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/request.py +0 -0
  39. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/span.py +0 -0
  40. {itd_sdk-2.1.2 → itd_sdk-2.3.0}/itd/user.py +0 -0
  41. {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.1.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.retry_on_rate_limits:
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 (RateLimitError, InternalError) as e:
380
- retry_after = getattr(e, 'retry_after', client.config.rate_limit_wait) or client.config.rate_limit_wait
381
- l.info('rate limit on %s; wait %ss', func.__name__, retry_after)
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
- rate_limit_wait: int = 10 # delay before next attempt (after rate limit error) if retry_after is not provided
49
- retry_on_rate_limits: bool = True
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
- self.refresh_auth()
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
- def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}):
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
- FILE = 'file'
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 _HIERARCHY.index(self) > _HIERARCHY.index(other)
126
+ return _ACCESS_TYPE_HIERARCHY.index(self) > _ACCESS_TYPE_HIERARCHY.index(other)
85
127
 
86
128
  def __lt__(self, other):
87
- return _HIERARCHY.index(self) < _HIERARCHY.index(other)
129
+ return _ACCESS_TYPE_HIERARCHY.index(self) < _ACCESS_TYPE_HIERARCHY.index(other)
88
130
 
89
131
  def __ge__(self, other):
90
- return _HIERARCHY.index(self) >= _HIERARCHY.index(other)
132
+ return _ACCESS_TYPE_HIERARCHY.index(self) >= _ACCESS_TYPE_HIERARCHY.index(other)
91
133
 
92
134
  def __le__(self, other):
93
- return _HIERARCHY.index(self) <= _HIERARCHY.index(other)
135
+ return _ACCESS_TYPE_HIERARCHY.index(self) <= _ACCESS_TYPE_HIERARCHY.index(other)
94
136
 
95
- _HIERARCHY = [AccessType.EVERYONE, AccessType.FOLLOWERS, AccessType.MUTUAL, AccessType.EVERYONE] # 100% that hierarcy is spelled wrong
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(ITDException): # you are banned
248
- code = 'ACCOUNT_BANNED'
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 = (RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(), AccountBannedError(), InternalError(), ProfileRequiredError())
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 uuid import UUID
2
- from datetime import datetime
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
- from itd.utils import to_uuid, parse_datetime, format_attachments, ATTACHMENTS, parse_html, parse_md
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 = True
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
- view_post(client or self.client, self.id)
229
- if (client or self.client) == self.client:
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.1.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"
@@ -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