itd-sdk 2.2.0__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 (40) hide show
  1. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/PKG-INFO +1 -2
  2. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/auth.py +13 -1
  3. itd_sdk-2.3.0/itd/api/dwell.py +19 -0
  4. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/base.py +6 -0
  5. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/client.py +23 -0
  6. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/comment.py +9 -1
  7. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/enums.py +23 -1
  8. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/exceptions.py +26 -3
  9. itd_sdk-2.3.0/itd/file.py +160 -0
  10. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/post.py +231 -32
  11. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/pyproject.toml +3 -4
  12. itd_sdk-2.2.0/itd/file.py +0 -94
  13. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/README.md +0 -0
  14. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/__init__.py +0 -0
  15. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/_default.py +0 -0
  16. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/__init__.py +0 -0
  17. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/comments.py +0 -0
  18. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/etc.py +0 -0
  19. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/files.py +0 -0
  20. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/hashtags.py +0 -0
  21. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/notifications.py +0 -0
  22. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/pins.py +0 -0
  23. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/polls.py +0 -0
  24. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/posts.py +0 -0
  25. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/reports.py +0 -0
  26. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/search.py +0 -0
  27. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/subscription.py +0 -0
  28. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/users.py +0 -0
  29. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/verification.py +0 -0
  30. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/clan.py +0 -0
  31. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/hashtag.py +0 -0
  32. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/logger.py +0 -0
  33. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/notification.py +0 -0
  34. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/pin.py +0 -0
  35. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/poll.py +0 -0
  36. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/report.py +0 -0
  37. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/request.py +0 -0
  38. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/span.py +0 -0
  39. {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/user.py +0 -0
  40. {itd_sdk-2.2.0 → 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.2.0
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
@@ -5,7 +5,11 @@ from requests import Response
5
5
 
6
6
  from itd.base import catch_errors, rate_limit
7
7
  from itd.enums import AuthLevel
8
- from itd.exceptions import InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError, SessionExpiredError, SessionRevokedError
8
+ from itd.exceptions import (
9
+ InvalidPasswordError, SamePasswordError, InvalidOldPasswordError, SessionNotFoundError,
10
+ SessionExpiredError, SessionRevokedError, InvalidCredentials, CaptchaFailedError,
11
+ EmailDomainNotAllowed
12
+ )
9
13
  if TYPE_CHECKING:
10
14
  from itd.client import Client
11
15
 
@@ -14,10 +18,18 @@ if TYPE_CHECKING:
14
18
  def refresh_token(client: Client) -> Response:
15
19
  return client.request('post', 'v1/auth/refresh', level=AuthLevel.REFRESH)
16
20
 
21
+ @rate_limit()
17
22
  @catch_errors(InvalidPasswordError(), SamePasswordError(), InvalidOldPasswordError())
18
23
  def change_password(client: Client, old: str, new: str) -> Response:
19
24
  return client.request('post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old})
20
25
 
26
+ @rate_limit()
21
27
  @catch_errors()
22
28
  def logout(client: Client) -> Response:
23
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)})
@@ -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
@@ -264,6 +267,7 @@ def refresh_wrapper(func):
264
267
 
265
268
  # self._loading = False
266
269
  self._loaded = True
270
+ self._post_refresh()
267
271
 
268
272
  return wrapper
269
273
 
@@ -292,6 +296,8 @@ def catch_errors(*exceptions: ITDException):
292
296
 
293
297
  assert isinstance(res, Response)
294
298
  if res.status_code == 204:
299
+ if client.config.debug_response != DebugResponseMode.NO:
300
+ l.debug('no response')
295
301
  return res
296
302
 
297
303
  if client.config.debug_response == DebugResponseMode.BEFORE:
@@ -14,6 +14,7 @@ from itd.hashtag import Hashtag
14
14
  from itd.request import fetch, decode_jwt_payload
15
15
  from itd.enums import RateLimitMode, All, DebugResponseMode, ParseMode, Batch, BATCH, UserAgent, AuthLevel
16
16
  from itd.user import Me, User
17
+ from itd.post import DwellTracker
17
18
  from itd.api.auth import refresh_token, change_password, logout
18
19
  from itd.api.search import search
19
20
  from itd.api.users import get_follow_status
@@ -29,31 +30,47 @@ class Config:
29
30
  rate_limit: RateLimitMode = RateLimitMode.MID
30
31
  rate_limit_default: int | None = None # overrides ratelimit mode # rate limit for standard actions
31
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
+
32
34
  # is_logging_enabled: bool = True # TODO
33
35
  # logging_level = 'DEBUG'
36
+
34
37
  is_default: bool = False
38
+
35
39
  userposts_add_pinned_post: bool = True
40
+
36
41
  auto_load: bool = True
37
42
  load_on_getitem: int | All | Batch | None = 1
38
43
  load_on_iter: int | All | Batch | None = BATCH
39
44
  force_load_lists: bool = False # load lists even if has_more is False
45
+
40
46
  debug_response: DebugResponseMode = DebugResponseMode.NO
47
+
41
48
  timeout: float = 30
42
49
  timeout_file: float = 120
50
+
43
51
  url: str = 'xn--d1ah4a.com'
44
52
  url_api: str | None = None
45
53
  user_agent: UserAgent | str = UserAgent.BROWSER
46
54
  solve_challenge: bool = True
55
+
47
56
  load_comments_from_post: bool = False
57
+
48
58
  parse_mode: ParseMode = ParseMode.NO
59
+
49
60
  rate_limit_wait: int | None = None # DEPRECATED
50
61
  retry_on_rate_limits: bool | None = None # DEPRECATED
51
62
  retry_enabled: bool = True
52
63
  retry_delay: float = 10 # delay before next attempt (after rate limit error) if retry_after is not provided in request
53
64
  retry_max_retries: int | None = None # none for no limit
54
65
  retry_exceptions: tuple[type[Exception]] | list[type[Exception]] | None = None
66
+
55
67
  bypass_auth_level: bool = False
56
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
73
+
57
74
  def __post_init__(self):
58
75
  if self.rate_limit_default:
59
76
  self._rate_limit_default = self.rate_limit_default
@@ -120,6 +137,12 @@ class Client:
120
137
  if _default_client is None or config.is_default:
121
138
  set_default_client(self)
122
139
 
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
+
123
146
  def request(self, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader | bytes]] = {}, level=AuthLevel.ACCESS):
124
147
  """Сделать запрос
125
148
 
@@ -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,28 @@
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
+
4
26
  class AuthLevel(Enum):
5
27
  NO = 'no'
6
28
  ACCESS = 'access'
@@ -77,7 +99,7 @@ class AttachType(Enum):
77
99
  AUDIO = 'audio'
78
100
  IMAGE = 'image'
79
101
  VIDEO = 'video'
80
- FILE = 'file'
102
+ MEDIA = 'media'
81
103
 
82
104
  class PostsTab(Enum):
83
105
  FOLLOWING = 'following'
@@ -95,6 +95,21 @@ class AccessTokenExpiredError(AuthError):
95
95
  json_check = staticmethod(lambda json: json.get('error') == 'token expired')
96
96
 
97
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
+
98
113
  class PasswordError(ITDException): pass
99
114
 
100
115
  class SamePasswordError(PasswordError, ValidateError):
@@ -247,13 +262,21 @@ class TargetUserBannedError(ITDException): # target banned (eg if you try to fol
247
262
  message = 'Этот аккаунт заблокирован'
248
263
  text = 'Target user has been deactivated'
249
264
 
250
- class AccountBannedError(ITDException): # you are banned
251
- 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'))
252
267
  text = 'Account has been deactivated'
253
268
 
269
+ class AccountTemporarilyBannedError(LoginError):
270
+ code = 'ACCOUNT_DEACTIVATED'
271
+ text = 'Account has been temporarily deactivated'
272
+
254
273
  class ProfileRequiredError(ITDException):
255
274
  code = 'PROFILE_REQUIRED'
256
275
  text = 'No profile. Please create your profile first'
257
276
 
258
277
 
259
- DEFAULT_ERRORS = (RateLimitError(), InvalidAccessTokenError(), UnauthorizedError(), AccessTokenExpiredError(), AccountBannedError(), InternalError(), ProfileRequiredError(), RefreshTokenMissingError())
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,9 +1,12 @@
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
11
  from itd.comment import Comment, Comments
9
12
  from itd.file import PostAttach
@@ -18,23 +21,189 @@ from itd.api.posts import (
18
21
  delete_post, restore_post, edit_post, get_posts, get_user_posts, get_liked_posts
19
22
  )
20
23
  from itd.api.hashtags import get_posts_by_hashtag
24
+ from itd.api.dwell import send_views, send_interactions
21
25
  from itd.base import ITDBaseModel, refresh_wrapper, ITDList
22
- from itd.client import Client
23
- from itd.enums import PostsTab, UserPostSorting, ReportReason, ReportTargetType, ParseMode, ALL
26
+ from itd.enums import PostsTab, UserPostSorting, ReportReason, ReportTargetType, ParseMode, ALL, ViewReason, ViewSource, InteractionType
24
27
  from itd.logger import get_logger
25
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
26
31
 
27
32
  l = get_logger('post')
28
33
 
29
- # class ViewerSession:
30
- # def __init__(self, payload: dict) -> None:
31
- # self.p = self.post_id = UUID(payload['p'])
32
- # self.u = self.user_id = UUID(payload['u'])
33
- # self.e = self.expires_at = datetime.fromtimestamp(payload['e'])
34
- # if isinstance(payload['s'], list):
35
- # self.s = self.source = [ViewSource(source) for source in payload['s']]
36
- # else:
37
- # self.s = self.source = ViewSource(payload['s'])
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
+
38
207
 
39
208
 
40
209
  class Post(ITDBaseModel):
@@ -70,16 +239,25 @@ class Post(ITDBaseModel):
70
239
  wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId')
71
240
  wall_recipient: User | None = Field(None, alias='wallRecipient')
72
241
  # vs: ViewerSession
73
- vs: str # from 13.05 it is string token
242
+ vs: str = Field('') # from 13.05 it is string token
74
243
 
75
244
 
76
- 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:
77
246
  self.id = to_uuid(id)
247
+ self.source = source
248
+ self.source_context = source_context
249
+
78
250
  super().__init__(client)
79
251
 
80
252
  def for_client(self, client: Client):
81
253
  return Post(self.id, client=client)
82
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
+
83
261
 
84
262
  @classmethod
85
263
  def new(
@@ -130,12 +308,13 @@ class Post(ITDBaseModel):
130
308
  for name, value in validated.__dict__.items():
131
309
  setattr(instance, name, value)
132
310
 
133
- instance._loaded = True
311
+ instance._loaded = False
312
+ instance._post_refresh()
134
313
 
135
314
  return instance
136
315
 
137
316
  @classmethod
138
- 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':
139
318
  instance = cls.__new__(cls)
140
319
  super(Post, instance).__init__(client)
141
320
 
@@ -145,6 +324,9 @@ class Post(ITDBaseModel):
145
324
  setattr(instance, name, value)
146
325
 
147
326
  instance._loaded = set_loaded
327
+ instance.source = source
328
+ instance.source_context = source_context
329
+ instance._post_refresh()
148
330
  return instance
149
331
 
150
332
 
@@ -232,14 +414,21 @@ class Post(ITDBaseModel):
232
414
 
233
415
  return Post._from_dict(post, client=client)
234
416
 
235
- 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:
236
418
  """Просмотреть пост
237
419
 
238
420
  Args:
239
421
  client (Client | None, optional): Клиент. Defaults to None.
240
422
  """
241
- view_post(client or self.client, self.id)
242
- 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:
243
432
  self.is_viewed = True
244
433
  # post can be already viewed, so view will not add; thats why do not change views_count
245
434
 
@@ -326,17 +515,14 @@ class Post(ITDBaseModel):
326
515
  def url(self) -> str:
327
516
  return f'https://xn--d1ah4a.com/@{self.author.username}/post/{self.id}'
328
517
 
329
- if not TYPE_CHECKING:
330
- def __getattribute__(self, name: str):
331
- value = super().__getattribute__(name)
332
- if name == 'comments' and getattr(value, '_post_id', None) is None:
333
- value = Comments()
334
- value._post_id = self.id
335
- return value
336
-
337
518
 
338
519
 
339
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
+
340
526
  @field_validator('edited_at', mode='plain')
341
527
  @classmethod
342
528
  def validate_edited_at(cls, v: str | None):
@@ -354,7 +540,7 @@ class _PostValidate(BaseModel, Post): # BaseModel MUST be first or you ll have s
354
540
  def validate_original_post(cls, post: dict | None = None):
355
541
  if post is None:
356
542
  return
357
- return Post._from_dict(post, False)
543
+ return Post._from_dict(post, set_loaded=False)
358
544
 
359
545
  @field_validator('poll', mode='plain')
360
546
  @classmethod
@@ -392,6 +578,8 @@ class _PostValidate(BaseModel, Post): # BaseModel MUST be first or you ll have s
392
578
 
393
579
  class _BasePosts(ITDList[Post]):
394
580
  _limit = 50
581
+ source: ViewSource
582
+ source_context: str | None = None
395
583
 
396
584
  @staticmethod
397
585
  def _get_cursor(data: dict):
@@ -406,7 +594,7 @@ class _BasePosts(ITDList[Post]):
406
594
  return data['posts']
407
595
 
408
596
  def _extend(self, objects: list, client: Client):
409
- 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])
410
598
 
411
599
  def __setattr__(self, name: str, value) -> None:
412
600
  if name == '_client':
@@ -422,6 +610,13 @@ class Posts(_BasePosts):
422
610
  def __init__(self, tab: PostsTab = PostsTab.POPULAR, client: Client | None = None) -> None:
423
611
  super().__init__(client)
424
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
425
620
 
426
621
  def _fetch(self, client: Client, limit: int) -> dict:
427
622
  return get_posts(client, self.cursor, limit, self.tab).json()['data']
@@ -447,6 +642,7 @@ class UserPosts(_BasePosts):
447
642
  _load_with_parent = False
448
643
  cursor: datetime | None = None
449
644
  _force_remove_pinned_post: bool = False
645
+ source = ViewSource.PROFILE
450
646
 
451
647
  # ! not includes posts from other users (wall posts)
452
648
  # def _get_total(self, data: dict):
@@ -464,6 +660,7 @@ class UserPosts(_BasePosts):
464
660
  raise ValueError('User must be instance of User or Me class')
465
661
 
466
662
  self.sorting = sorting # sort is busy
663
+ self.source_context = str(self.user.id)
467
664
 
468
665
  def _fetch(self, client: Client, limit: int) -> dict:
469
666
  if self.sorting == UserPostSorting.NEW and client.config.userposts_add_pinned_post and not self._force_remove_pinned_post:
@@ -525,6 +722,7 @@ class LikedPosts(_BasePosts): # [] if forbidden
525
722
  class HashtagPosts(_BasePosts):
526
723
  hashtag: Hashtag
527
724
  cursor: UUID | None = None
725
+ source = ViewSource.HASHTAG
528
726
 
529
727
  def __init__(self, hashtag: Hashtag | str, client: Client | None = None) -> None:
530
728
  super().__init__(client)
@@ -532,12 +730,13 @@ class HashtagPosts(_BasePosts):
532
730
  if isinstance(hashtag, str):
533
731
  hashtag = Hashtag(hashtag, self.client)
534
732
  self.hashtag = hashtag
733
+ self.source_context = self.hashtag.name
535
734
 
536
735
  def _fetch(self, client: Client, limit: int) -> dict:
537
736
  return get_posts_by_hashtag(client, self.hashtag.name, self.cursor, limit).json()['data']
538
737
 
539
738
  def _extend(self, objects: list, client: Client):
540
- 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])
541
740
 
542
741
  def _get_total(self, data: dict):
543
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.2.0"
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.2.0/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
File without changes