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.
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/PKG-INFO +1 -2
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/auth.py +13 -1
- itd_sdk-2.3.0/itd/api/dwell.py +19 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/base.py +6 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/client.py +23 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/comment.py +9 -1
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/enums.py +23 -1
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/exceptions.py +26 -3
- itd_sdk-2.3.0/itd/file.py +160 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/post.py +231 -32
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/pyproject.toml +3 -4
- itd_sdk-2.2.0/itd/file.py +0 -94
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/README.md +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/__init__.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/_default.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/__init__.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/comments.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/etc.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/files.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/hashtags.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/notifications.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/pins.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/polls.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/posts.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/reports.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/search.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/subscription.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/users.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/api/verification.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/clan.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/hashtag.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/logger.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/notification.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/pin.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/poll.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/report.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/request.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/span.py +0 -0
- {itd_sdk-2.2.0 → itd_sdk-2.3.0}/itd/user.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
251
|
-
|
|
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 = (
|
|
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
|
|
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
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.
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
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
|
-
|
|
242
|
-
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:
|
|
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.
|
|
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
|
|
File without changes
|