Merge branch 'main' into main

This commit is contained in:
kilyabin
2026-03-01 14:09:26 +04:00
committed by GitHub
26 changed files with 701 additions and 206 deletions

0
' Normal file
View File

3
.gitignore vendored
View File

@@ -4,4 +4,5 @@ venv/
__pycache__/
dist
itd_sdk.egg-info
nowkie.gif
nowkie.gif
g.gif

View File

@@ -1,5 +1,9 @@
# itd-sdk
Клиент ITD для python
> [!CAUTION]
> ~~Мой основной аккаунт itd_sdk был забанен. Новый акк - itdsdk. #димонверниаккаунты~~
> ~~Проект больше не будет обнолвяться! яPR буду мержить~~
> ладно, буду мейнтйнить потихоньку...
## Установка
@@ -18,10 +22,25 @@ c = ITDClient('TOKEN', 'refresh_token=...; __ddg1_=...; __ddgid_=...; is_auth=1;
print(c.get_me())
```
<!--
> [!NOTE]
> Берите куки из запроса /auth/refresh. В остальных запросах нету refresh_token
> ![cookie](cookie-screen.png)
> ![cookie](cookie-screen.png) -->
### Получение cookies
Для получения access_token требуются cookies с `refresh_token`. Как их получить:
1. Откройте [итд.com](https://xn--d1ah4a.com) в браузере
2. Откройте DevTools (F12)
3. Перейдите на вкладку **Network**
4. Обновите страницу
5. Найдите запрос к `/auth/refresh`
6. Скопируйте значение **Cookie** из Request Headers
> Пример: `refresh_token=123123A67BCdEfGG; is_auth=1`
> В cookies также могут присутствовать значения типа `__ddgX__` (DDoS-Guard cookies) или `_ym_XXXX` (`X` - любое число или буква). Они необязательные и их наличие не влияет на результат
![cookie](cookie-screen.png)
---
### Скрипт на обновление имени
@@ -46,10 +65,9 @@ while True:
```python
from itd import ITDClient
c = ITDClient(None, '...')
c = ITDClient(None, 'Ваши cookies')
id = c.upload_file('любое-имя.png', open('реальное-имя-файла.png', 'rb'))['id']
c.update_profile(banner_id=id)
c.update_banner('имя-файла.png')
print('баннер обновлен')
```
@@ -64,7 +82,27 @@ c.create_post('тест1') # создание постов
# итд
```
### SSE - прослушивание уведомлений в реальном времени
### Стилизация постов ("спаны")
С обновления 1.3.0 добавлена функция "спанов". Для парсинга пока поддерживается только html, но в будущем будет добавлен markdown.
```python
from itd import ITDClient
from itd.utils import parse_html
с = ITDClient(cookies='refresh_token=123')
print(с.create_post(*parse_html('значит, я это щас отправил со своего клиента, <b>воот</b>. И еще тут спаны написаны через html, по типу < i > <i>11</i>')))
```
Поддерживаемые теги:
- `<b>`: жирный
- `<i>`: курсивный
- `<s>`: зачеркнутый
- `<u>`: подчеркнутый
- `<code>`: код
- `<spoiler>`: спойлер
- `<a href="https://google.com">`: ссылка
- `<q>`: цитата
<!-- ### SSE - прослушивание уведомлений в реальном времени
```python
from itd import ITDClient, StreamConnect, StreamNotification
@@ -88,7 +126,7 @@ for event in c.stream_notifications():
- `wall_post` - пост на вашей стене
- `comment` - комментарий к посту
- `reply` - ответ на комментарий
- `repost` - репост вашего поста
- `repost` - репост вашего поста -->
### Кастомные запросы
@@ -103,17 +141,10 @@ fetch(c.token, 'метод', 'эндпоинт', {'данные': 'данные'
> [!NOTE]
> `xn--d1ah4a.com` - punycode от "итд.com"
## Планы
- Форматированные сообщения об ошибках
- Логирование (через logging)
- Добавление ООП (отдеьные классы по типу User или Post вместо обычного JSON)
- Голосовые сообщения
## Прочее
Лицезия: [MIT](./LICENSE)
Лицезия: [MIT](./LICENSE)
Идея (и часть эндпоинтов): https://github.com/FriceKa/ITD-SDK-js
- По сути этот проект является реворком, просто на другом языке
Автор: [itd_sdk](https://xn--d1ah4a.com/itd_sdk) (в итд) [@desicars](https://t.me/desicars) (в тг)
Автор: ~~[itd_sdk](https://xn--d1ah4a.com/itd_sdk) забанили~~ [itdsdk](https://xn--d1ah4a.com/itdsdk) (в итд) [@desicars](https://t.me/desicars) (в тг)

View File

@@ -2,62 +2,6 @@
Эта папка содержит примеры использования ITD SDK для различных сценариев.
## Структура
```
examples/
├── README.md # Этот файл
└── stream/ # Примеры работы с SSE потоком уведомлений
├── basic_stream.py
├── stop_stream.py
├── filter_notifications.py
└── notification_logger.py
```
## Подготовка
Перед запуском примеров установите зависимости:
```bash
pip install -r ../requirements.txt
```
## Получение cookies
Все примеры требуют cookies с `refresh_token`. Как их получить:
1. Откройте [итд.com](https://xn--d1ah4a.com) в браузере
2. Откройте DevTools (F12)
3. Перейдите на вкладку **Network**
4. Найдите запрос к `/auth/refresh`
5. Скопируйте значение **Cookie** из Request Headers
6. Формат: `refresh_token=...; __ddg1_=...; is_auth=1`
См. `cookie-screen.png` в корне проекта для примера.
---
## Stream - Прослушивание уведомлений
Примеры работы с SSE потоком уведомлений в реальном времени.
📁 **Папка:** `stream/`
📖 **Документация:** [stream/README.md](stream/README.md)
**Примеры:**
- `basic_stream.py` - Базовое прослушивание всех уведомлений
- `stop_stream.py` - Программная остановка потока
- `filter_notifications.py` - Фильтрация по типу уведомлений
- `notification_logger.py` - Логирование в JSON файл
**Быстрый старт:**
```bash
cd stream
python basic_stream.py
```
---
## Дополнительная информация
- [Основной README](../README.md) - Документация по всему SDK

View File

@@ -0,0 +1,7 @@
# Коллекция скриптов от [@kilyabin](https://github.com/kilyabin)
Пока всего два скрипта, однако будет дополняться
Работают через аргументы командной строки (например, `--file` или `--text`)
Есть помощь при аргументе `-h`, потому - разберетесь

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
from argparse import ArgumentParser
from os import getenv
from os.path import isfile
import sys
from itd import ITDClient
def main():
parser = ArgumentParser(
description='Upload image and set it as profile banner'
)
parser.add_argument(
'--token',
default=getenv('ITD_TOKEN'),
help='API token (or ITD_TOKEN env var)'
)
parser.add_argument(
'--file',
required=True,
help='Path to image file'
)
args = parser.parse_args()
if not args.token:
print('❌ Токен не задан (--token или ITD_TOKEN)', file=sys.stderr)
quit()
file_path = args.file
if not isfile(file_path):
print(f'❌ Файл не найден: {file_path}', file=sys.stderr)
quit()
try:
client = ITDClient(None, args.token)
data, _ = client.update_banner_new(file_path)
print('✅ Баннер обновлён!')
print('📄 Информация о файле:')
print(f' id: {data.id}')
print(f' filename: {data.filename}')
print(f' mime_type: {data.mime_type}')
print(f' size: {data.size} bytes')
print(f' url: {data.url}')
except Exception as e:
print('❌ Произошла ошибка:', e, file=sys.stderr)
quit()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
from uuid import UUID
from argparse import ArgumentParser
from os import getenv
from os.path import isfile, basename
from itd import ITDClient
def main():
parser = ArgumentParser(
description='Create a post on ITD via CLI'
)
parser.add_argument(
'--token',
default=getenv('ITD_TOKEN'),
help='Refresh token (or set ITD_TOKEN environment variable)'
)
parser.add_argument(
'--text',
required=True,
help='Text content of the post'
)
parser.add_argument(
'--file',
help='Optional file to attach to the post'
)
parser.add_argument(
'--filename',
help='Filename on server (if --file is used, default: local filename)'
)
args = parser.parse_args()
if not args.token:
print('❌ Token not provided (--token or ITD_TOKEN)')
quit()
try:
client = ITDClient(None, args.token)
file_id = None
if args.file:
if not isfile(args.file):
print(f'❌ File not found: {args.file}')
quit()
server_name = args.filename or basename(args.file)
with open(args.file, 'rb') as f:
response = client.upload_file(server_name, f)
file_id = str(getattr(response, 'id', None))
if not file_id:
print('❌ Failed to get file ID')
quit()
print(f'✅ File uploaded: {response.filename} (id={file_id})')
# Создаём пост с правильным аргументом 'content'
if file_id:
post_resp = client.create_post(content=args.text, attach_ids=[UUID(file_id)])
else:
post_resp = client.create_post(content=args.text)
print('✅ Post created successfully!')
print(f' id: {post_resp.id}')
print(f' text: {args.text}')
if file_id:
print(f' attached file id: {file_id}')
except Exception as e:
print('❌ Error:', e)
quit()
if __name__ == '__main__':
main()

View File

@@ -5,20 +5,20 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from itd import ITDClient, StreamConnect, StreamNotification
from itd import ITDClient, StreamConnect
def main():
cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies')
print(' См. examples/README.md для инструкций')
return
client = ITDClient(cookies=cookies)
print('-- Подключение к SSE...')
try:
for event in client.stream_notifications():
if isinstance(event, StreamConnect):
@@ -29,7 +29,7 @@ def main():
if event.preview:
preview = event.preview[:50] + '...' if len(event.preview) > 50 else event.preview
print(f' {preview}')
except KeyboardInterrupt:
print(f'\n! Отключение...')

View File

@@ -5,48 +5,48 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from itd import ITDClient, StreamConnect, StreamNotification
from itd import ITDClient, StreamConnect
from itd.enums import NotificationType
def main():
cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies')
print(' См. examples/README.md для инструкций')
return
client = ITDClient(cookies=cookies)
# Настройка: какие типы уведомлений показывать
SHOW_TYPES = {
NotificationType.LIKE,
NotificationType.FOLLOW,
NotificationType.COMMENT,
NotificationType.COMMENT
}
print('-- Подключение к SSE...')
print(f'-- Фильтр: {", ".join(t.value for t in SHOW_TYPES)}\n')
try:
for event in client.stream_notifications():
if isinstance(event, StreamConnect):
print(f'✅ Подключено! User ID: {event.user_id}\n')
continue
if event.type not in SHOW_TYPES:
continue
# Обработка разных типов
if event.type == NotificationType.LIKE:
print(f'❤️ {event.actor.display_name} лайкнул ваш пост')
elif event.type == NotificationType.FOLLOW:
print(f'👤 {event.actor.display_name} подписался на вас')
elif event.type == NotificationType.COMMENT:
print(f'💬 {event.actor.display_name}: {event.preview}')
except KeyboardInterrupt:
print(f'\n! Отключение...')

View File

@@ -5,29 +5,29 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from itd import ITDClient, StreamConnect, StreamNotification
from itd import ITDClient, StreamConnect
from datetime import datetime
import json
def main():
cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies')
print(' См. examples/README.md для инструкций')
return
client = ITDClient(cookies=cookies)
log_file = f'notifications_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
print(f'-- Подключение к SSE...')
print(f'-- Логирование в: {log_file}\n')
try:
with open(log_file, 'w', encoding='utf-8') as f:
for event in client.stream_notifications():
timestamp = datetime.now().isoformat()
if isinstance(event, StreamConnect):
log_entry = {
'timestamp': timestamp,
@@ -49,10 +49,10 @@ def main():
'target_id': str(event.target_id) if event.target_id else None
}
print(f'* {event.type.value}: {event.actor.username}')
f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
f.flush()
except KeyboardInterrupt:
print(f'\n! Отключение... Лог сохранен в {log_file}')

View File

@@ -6,19 +6,17 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import threading
import time
from itd import ITDClient, StreamConnect, StreamNotification
from itd import ITDClient, StreamConnect
def main():
cookies = 'YOUR_COOKIES_HERE'
cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies')
return
client = ITDClient(cookies=cookies)
# Функция для прослушивания в отдельном потоке
def listen():
print('! Начинаем прослушивание...')
try:
@@ -29,17 +27,16 @@ def main():
print(f'🔔 {event.type.value}: {event.actor.username}')
except Exception as e:
print(f'! Ошибка: {e}')
# В отдельном потоке
thread = threading.Thread(target=listen, daemon=True)
thread.start()
print('Прослушивание запущено. Нажмите Enter для остановки...')
input()
print('!! Останавливаем прослушивание...')
client.stop_stream()
thread.join(timeout=5)
print('! Остановлено')

View File

@@ -1,20 +1,19 @@
# from warnings import deprecated
from uuid import UUID
from _io import BufferedReader
from typing import cast, Iterator
from datetime import datetime
import json
import time
from json import JSONDecodeError, loads
from time import sleep
from requests.exceptions import ConnectionError, HTTPError
from sseclient import SSEClient
from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy
from itd.routes.users import get_user, update_profile, follow, unfollow, get_followers, get_following, update_privacy, update_privacy_new
from itd.routes.etc import get_top_clans, get_who_to_follow, get_platform_status
from itd.routes.comments import get_comments, add_comment, delete_comment, like_comment, unlike_comment, add_reply_comment, get_replies
from itd.routes.hashtags import get_hashtags, get_posts_by_hashtag
from itd.routes.notifications import get_notifications, mark_as_read, mark_all_as_read, get_unread_notifications_count, stream_notifications
from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, unlike_post, get_user_posts
from itd.routes.posts import create_post, get_posts, get_post, edit_post, delete_post, pin_post, repost, view_post, get_liked_posts, restore_post, like_post, unlike_post, get_user_posts, vote
from itd.routes.reports import report
from itd.routes.search import search
from itd.routes.files import upload_file, get_file, delete_file
@@ -24,10 +23,10 @@ from itd.routes.pins import get_pins, remove_pin, set_pin
from itd.models.comment import Comment
from itd.models.notification import Notification
from itd.models.post import Post, NewPost
from itd.models.post import Post, NewPost, PollData, Poll, Span
from itd.models.clan import Clan
from itd.models.hashtag import Hashtag
from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow
from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow, UserPrivacyData
from itd.models.pagination import Pagination, PostsPagintaion, LikedPostsPagintaion
from itd.models.verification import Verification, VerificationStatus
from itd.models.report import NewReport
@@ -41,7 +40,7 @@ from itd.exceptions import (
NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned,
PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized,
CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned, NoContent,
AlreadyFollowing, NotFoundOrForbidden
AlreadyFollowing, NotFoundOrForbidden, OptionsNotBelong, NotMultipleChoice, EmptyOptions
)
@@ -195,7 +194,7 @@ class Client:
@refresh_on_error
def update_privacy(self, wall_closed: bool = False, private: bool = False) -> UserPrivacy:
"""Обновить настройки приватности
"""(УСТАРЕЛО! Используйте update_privacy_new) настройки приватности
Args:
wall_closed (bool, optional): Закрыть стену. Defaults to False.
@@ -209,6 +208,21 @@ class Client:
return UserPrivacy.model_validate(res.json())
@refresh_on_error
def update_privacy_new(self, privacy: UserPrivacyData) -> UserPrivacy:
"""Обновить настройки приватности
Args:
privacy (UserPrivacyData): Данные приватности
Returns:
UserPrivacy: Обновленные данные приватности
"""
res = update_privacy_new(self.token, privacy)
res.raise_for_status()
return UserPrivacy.model_validate(res.json())
@refresh_on_error
def follow(self, username: str) -> int:
"""Подписаться на пользователя
@@ -455,13 +469,13 @@ class Client:
@refresh_on_error
def get_replies(self, comment_id: UUID, limit: int = 50, page: int = 1, sort: str = 'oldest') -> tuple[list[Comment], Pagination]:
"""Получить список комментариев
"""Получить список ответов на комментарий
Args:
comment_id (UUID): UUID поста
limit (int, optional): Лимит. Defaults to 50.
page (int, optional): Курсор (сколько пропустить). Defaults to 1.
sort (str, optional): Сортировка. Defaults to 'oldesr'.
sort (str, optional): Сортировка. Defaults to 'oldest'.
Raises:
NotFound: Пост не найден
@@ -616,7 +630,6 @@ class Client:
res = mark_all_as_read(self.token)
res.raise_for_status()
@refresh_on_error
def get_unread_notifications_count(self) -> int:
"""Получить количество непрочитанных уведомлений
@@ -631,13 +644,15 @@ class Client:
@refresh_on_error
def create_post(self, content: str, wall_recipient_id: UUID | None = None, attach_ids: list[UUID] = []) -> NewPost:
def create_post(self, content: str | None = None, spans: list[Span] = [], wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = [], poll: PollData | None = None) -> NewPost:
"""Создать пост
Args:
content (str): Содержимое
content (str | None, optional): Содержимое. Defaults to None.
spans (lsit[Span], optional): Стилизация содержимого. Defaults to [].
wall_recipient_id (UUID | None, optional): UUID пользователя (чтобы создать пост ему на стене). Defaults to None.
attach_ids (list[UUID], optional): UUID вложений. Defaults to [].
attachment_ids (list[UUID], optional): UUID вложений. Defaults to [].
poll (PollData | None, optional): Опрос. Defaults to None.
Raises:
NotFound: Пользователь не найден
@@ -646,7 +661,8 @@ class Client:
Returns:
NewPost: Новый пост
"""
res = create_post(self.token, content, wall_recipient_id, attach_ids)
res = create_post(self.token, content, [span.model_dump(mode="json") for span in spans], wall_recipient_id, attachment_ids, poll.poll if poll else None)
if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Wall recipient')
if res.status_code == 422 and 'found' in res.json():
@@ -655,6 +671,41 @@ class Client:
return NewPost.model_validate(res.json())
@refresh_on_error
def vote(self, id: UUID, option_ids: list[UUID]) -> Poll:
"""Проголосовать в опросе
Args:
id (UUID): UUID поста
option_ids (list[UUID]): Список UUID вариантов
Raises:
EmptyOptions: Пустые варианты
NotFound: Пост не найден или в посте нет опроса
NotFound: _description_
OptionsNotBelong: Неверные варианты (варинты не пренадлежат опросу)
NotMultipleChoice: Можно выбрать только 1 вариант (для опросов, где не разрешены несколько ответов)
Returns:
Poll: Опрос
"""
if not option_ids:
raise EmptyOptions()
res = vote(self.token, id, option_ids)
if res.json().get('error', {}).get('code') == 'NOT_FOUND' and res.json().get('error', {}).get('message') == 'Опрос не найден':
raise NotFound('Poll')
if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Post')
if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and res.json().get('error', {}).get('message') == 'Один или несколько вариантов не принадлежат этому опросу':
raise OptionsNotBelong()
if res.json().get('error', {}).get('code') == 'VALIDATION_ERROR' and res.json().get('error', {}).get('message') == 'В этом опросе можно выбрать только один вариант':
raise NotMultipleChoice()
res.raise_for_status()
return Poll.model_validate(res.json()['data'])
@refresh_on_error
def get_posts(self, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR) -> tuple[list[Post], PostsPagintaion]:
"""Получить список постов
@@ -994,8 +1045,9 @@ class Client:
return File.model_validate(res.json())
# @deprecated # Этот декоратор появился в 3.13, а наша библиотека поддерживает с 3.9
def update_banner(self, name: str) -> UserProfileUpdate:
"""Обновить банер (шорткат из upload_file + update_profile)
"""[DEPRECATED] Обновить банер (шорткат из upload_file + update_profile)
Args:
name (str): Имя файла
@@ -1006,6 +1058,19 @@ class Client:
id = self.upload_file(name, cast(BufferedReader, open(name, 'rb'))).id
return self.update_profile(banner_id=id)
def update_banner_new(self, name: str) -> tuple[File, UserProfileUpdate]:
"""Обновить банер (шорткат из upload_file + update_profile)
Args:
name (str): Имя файла
Returns:
File: Загруженный файл
UserProfileUpdate: Обновленный профиль
"""
file = self.upload_file(name, cast(BufferedReader, open(name, 'rb')))
return file, self.update_profile(banner_id=file.id)
@refresh_on_error
def restore_post(self, post_id: UUID) -> None:
"""Восстановить удалённый пост
@@ -1088,62 +1153,63 @@ class Client:
return res.json()['pin']
@refresh_on_error
def stream_notifications(self) -> Iterator[StreamConnect | StreamNotification]:
"""Слушать SSE поток уведомлений
Yields:
StreamConnect | StreamNotification: События подключения или уведомления
Example:
```python
from itd import ITDClient
client = ITDClient(cookies='refresh_token=...')
# Запуск прослушивания
for event in client.stream_notifications():
if isinstance(event, StreamConnect):
print(f'Подключено: {event.user_id}')
else:
print(f'Уведомление: {event.type} от {event.actor.username}')
# Остановка из другого потока или обработчика
# client.stop_stream()
```
"""
self._stream_active = True
while self._stream_active:
try:
response = stream_notifications(self.token)
response.raise_for_status()
client = SSEClient(response)
for event in client.events():
if not self._stream_active:
response.close()
return
try:
if not event.data or event.data.strip() == '':
continue
data = json.loads(event.data)
data = loads(event.data)
if 'userId' in data and 'timestamp' in data and 'type' not in data:
yield StreamConnect.model_validate(data)
else:
yield StreamNotification.model_validate(data)
except json.JSONDecodeError:
except JSONDecodeError:
print(f'Не удалось распарсить сообщение: {event.data}')
continue
except Exception as e:
print(f'Ошибка обработки события: {e}')
continue
except Unauthorized:
if self.cookies and self._stream_active:
print('Токен истек, обновляем...')
@@ -1155,27 +1221,27 @@ class Client:
if not self._stream_active:
return
print(f'Ошибка соединения: {e}, переподключение через 5 секунд...')
time.sleep(5)
sleep(5)
continue
def stop_stream(self):
"""Остановить прослушивание SSE потока
Example:
```python
import threading
from itd import ITDClient
client = ITDClient(cookies='refresh_token=...')
# Запуск в отдельном потоке
def listen():
for event in client.stream_notifications():
print(event)
thread = threading.Thread(target=listen)
thread.start()
# Остановка через 10 секунд
import time
time.sleep(10)
@@ -1183,4 +1249,5 @@ class Client:
thread.join()
```
"""
print('stop event')
self._stream_active = False

View File

@@ -33,3 +33,21 @@ class AttachType(Enum):
class PostsTab(Enum):
FOLLOWING = 'following'
POPULAR = 'popular'
class AccessType(Enum):
"""Типы разрешений для видимости лайков и записей на стене"""
NOBODY = 'nobody' # никто
MUTUAL = 'mutual' # взаимные
FOLLOWERS = 'followers' # подписчики
EVERYONE = 'everyone' # все
class SpanType(Enum):
MONOSPACE = 'monospace' # моноширный (код)
STRIKE = 'strike' # зачеркнутый
BOLD = 'bold' # жирный
ITALIC = 'italic' # курсив
SPOILER = 'spoiler' # спойлер
UNDERLINE = 'underline' # подчеркнутый
HASHTAG = 'hashtag' # хэштэг ? (появляется только при получении постов, при создании нету)
LINK = 'link' # ссылка
QUOTE = 'quote' # цитата

View File

@@ -49,11 +49,11 @@ class UserBanned(Exception):
return 'User banned'
class ValidationError(Exception):
def __init__(self, name: str, value: str):
self.name = name
self.value = value
# def __init__(self, name: str, value: str):
# self.name = name
# self.value = value
def __str__(self):
return f'Failed validation on {self.name}: "{self.value}"'
return 'Failed validation'# on {self.name}: "{self.value}"'
class PendingRequestExists(Exception):
def __str__(self):
@@ -113,4 +113,24 @@ class NoContent(Exception):
class AlreadyFollowing(Exception):
def __str__(self) -> str:
return 'Already following user'
return 'Already following user'
class AccountBanned(Exception):
def __str__(self) -> str:
return 'Account has been deactivated'
class OptionsNotBelong(Exception):
def __str__(self) -> str:
return 'One or more options do not belong to poll'
class NotMultipleChoice(Exception):
def __str__(self) -> str:
return 'Only one option can be choosen in this poll'
class EmptyOptions(Exception):
def __str__(self) -> str:
return 'Options cannot be empty (pre-validation)'
class ProfileRequired(Exception):
def __str__(self) -> str:
return 'No profile. Please create your profile first'

View File

@@ -1,11 +1,8 @@
from uuid import UUID
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from itd.enums import NotificationType, NotificationTargetType
from itd.models.user import UserNotification
from itd.models.notification import Notification
class StreamConnect(BaseModel):
@@ -14,20 +11,7 @@ class StreamConnect(BaseModel):
timestamp: int
class StreamNotification(BaseModel):
class StreamNotification(Notification):
"""Уведомление из SSE потока"""
id: UUID
type: NotificationType
target_type: NotificationTargetType | None = Field(None, alias='targetType')
target_id: UUID | None = Field(None, alias='targetId')
preview: str | None = None
read_at: datetime | None = Field(None, alias='readAt')
created_at: datetime = Field(alias='createdAt')
user_id: UUID = Field(alias='userId')
actor: UserNotification
read: bool = False
sound: bool = True

View File

@@ -1,46 +1,104 @@
from uuid import UUID
from datetime import datetime
from pydantic import Field, BaseModel
from pydantic import Field, BaseModel, field_validator
from itd.models.user import UserPost, UserNewPost
from itd.models._text import TextObject
from itd.models.file import PostAttach
from itd.models.comment import Comment
from itd.enums import SpanType
class _PostShort(TextObject):
class NewPollOption(BaseModel):
text: str
class PollOption(NewPollOption):
id: UUID
position: int = 0
votes: int = Field(0, alias='votesCount')
class _Poll(BaseModel):
multiple: bool = Field(False, alias='multipleChoice')
question: str
class NewPoll(_Poll):
options: list[NewPollOption]
model_config = {'serialize_by_alias': True}
class PollData:
def __init__(self, question: str, options: list[str], multiple: bool = False):
self.poll = NewPoll(question=question, options=[NewPollOption(text=option) for option in options], multipleChoice=multiple)
class Poll(_Poll):
id: UUID
post_id: UUID = Field(alias='postId')
options: list[PollOption]
votes: int = Field(0, alias='totalVotes')
is_voted: bool = Field(False, alias='hasVoted')
voted_option_ids: list[UUID] = Field([], alias='votedOptionIds')
created_at: datetime = Field(alias='createdAt')
@field_validator('created_at', mode='plain')
@classmethod
def validate_created_at(cls, v: str):
try:
return datetime.strptime(v + '00', '%Y-%m-%d %H:%M:%S.%f%z')
except ValueError:
return datetime.strptime(v, '%Y-%m-%dT%H:%M:%S.%f')
class Span(BaseModel):
length: int
offset: int
type: SpanType
url: str | None = None
class _PostCounts(TextObject):
likes_count: int = Field(0, alias='likesCount')
comments_count: int = Field(0, alias='commentsCount')
reposts_count: int = Field(0, alias='repostsCount')
views_count: int = Field(0, alias='viewsCount')
spans: list[Span] = []
class PostShort(_PostShort):
class _PostAuthor(_PostCounts):
author: UserPost
class OriginalPost(PostShort):
class OriginalPost(_PostAuthor):
is_deleted: bool = Field(False, alias='isDeleted')
class _Post(_PostShort):
class _Post(_PostCounts):
is_liked: bool = Field(False, alias='isLiked')
is_reposted: bool = Field(False, alias='isReposted')
is_viewed: bool = Field(False, alias='isViewed')
is_owner: bool = Field(False, alias='isOwner')
is_pinned: bool = Field(False, alias='isPinned') # only for user wall
attachments: list[PostAttach] = []
comments: list[Comment] = []
original_post: OriginalPost | None = None
original_post: OriginalPost | None = None # for reposts
wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId')
wall_recipient: UserPost | None = Field(None, alias='wallRecipient')
class Post(_Post, PostShort):
pass
class Post(_Post, _PostAuthor):
poll: Poll | None = None
class NewPost(_Post):
author: UserNewPost
poll: NewPoll | None = None

View File

@@ -4,13 +4,41 @@ from datetime import datetime
from pydantic import BaseModel, Field
from itd.models.pin import ShortPin
from itd.enums import AccessType
class UserPrivacy(BaseModel):
class _UserPrivacy(BaseModel):
private: bool | None = Field(None, alias='isPrivate') # none for not me
wall_closed: bool = Field(False, alias='wallClosed')
wall_closed: bool | None = Field(None, alias='wallClosed', deprecated=True)
wall_access: AccessType = Field(AccessType.EVERYONE, alias='wallAccess')
likes_visibility: AccessType = Field(AccessType.EVERYONE, alias='likesVisibility')
model_config = {'populate_by_name': True}
model_config = {'serialize_by_alias': True}
class UserPrivacy(_UserPrivacy):
show_last_seen: bool = Field(True, alias='showLastSeen')
class UserPrivacyData:
def __init__(self, private: bool | None = None, wall_access: AccessType | None = None, likes_visibility: AccessType | None = None, show_last_seen: bool | None = None) -> None:
self.private = private
self.wall_access = wall_access
self.likes_visibility = likes_visibility
self.show_last_seen = show_last_seen
def to_dict(self):
data = {}
if self.private is not None:
data['isPrivate'] = self.private
if self.wall_access is not None:
data['wallAccess'] = self.wall_access.value
if self.likes_visibility is not None:
data['likesVisibility'] = self.likes_visibility.value
if self.show_last_seen is not None:
data['showLastSeen'] = self.show_last_seen
return data
class UserProfileUpdate(BaseModel):
@@ -51,7 +79,7 @@ class UserSearch(UserFollower, UserWhoToFollow):
pass
class User(UserSearch, UserPrivacy):
class User(UserSearch, _UserPrivacy):
banner: str | None = None
bio: str | None = None
pinned_post_id: UUID | None = Field(None, alias='pinnedPostId')
@@ -62,3 +90,5 @@ class User(UserSearch, UserPrivacy):
is_followed: bool | None = Field(None, alias='isFollowedBy') # none for me
created_at: datetime = Field(alias='createdAt')
last_seen_at: datetime | None = Field(None, alias='lastSeen')
online: bool = False

View File

@@ -3,7 +3,7 @@ from _io import BufferedReader
from requests import Session
from requests.exceptions import JSONDecodeError
from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded, Unauthorized
from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded, Unauthorized, AccountBanned, ProfileRequired
s = Session()
@@ -39,6 +39,10 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str,
raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0))
if res.json().get('error', {}).get('code') == 'UNAUTHORIZED':
raise Unauthorized()
if res.json().get('error', {}).get('code') in ('ACCOUNT_BANNED', 'USER_BLOCKED'):
raise AccountBanned()
if res.json().get('error', {}).get('code') == 'PROFILE_REQUIRED':
raise ProfileRequired()
except (JSONDecodeError, AttributeError):
pass # todo

View File

@@ -1,4 +1,3 @@
from warnings import deprecated
from uuid import UUID
from itd.request import fetch

View File

@@ -16,7 +16,7 @@ def get_unread_notifications_count(token: str):
def stream_notifications(token: str):
"""Получить SSE поток уведомлений
Returns:
Response: Streaming response для SSE
"""

View File

@@ -3,13 +3,18 @@ from uuid import UUID
from itd.request import fetch
from itd.enums import PostsTab
from itd.models.post import NewPoll
def create_post(token: str, content: str, wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = []):
data: dict = {'content': content}
def create_post(token: str, content: str | None = None, spans: list[dict] = [], wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = [], poll: NewPoll | None = None):
data: dict = {'content': content or ''}
if spans:
data['spans'] = spans
if wall_recipient_id:
data['wallRecipientId'] = str(wall_recipient_id)
if attachment_ids:
data['attachmentIds'] = list(map(str, attachment_ids))
if poll:
data['poll'] = poll.model_dump()
return fetch(token, 'post', 'posts', data)
@@ -43,11 +48,14 @@ def get_liked_posts(token: str, username_or_id: str | UUID, limit: int = 20, cur
def get_user_posts(token: str, username_or_id: str | UUID, limit: int = 20, cursor: datetime | None = None):
return fetch(token, 'get', f'posts/user/{username_or_id}', {'limit': limit, 'cursor': cursor})
def restore_post(token: str, post_id: UUID):
return fetch(token, "post", f"posts/{post_id}/restore",)
def restore_post(token: str, id: UUID):
return fetch(token, "post", f"posts/{id}/restore",)
def like_post(token: str, post_id: UUID):
return fetch(token, "post", f"posts/{post_id}/like")
def like_post(token: str, id: UUID):
return fetch(token, "post", f"posts/{id}/like")
def unlike_post(token: str, post_id: UUID):
return fetch(token, "delete", f"posts/{post_id}/like")
def unlike_post(token: str, id: UUID):
return fetch(token, "delete", f"posts/{id}/like")
def vote(token: str, id: UUID, options: list[UUID]):
return fetch(token, 'post', f'posts/{id}/poll/vote', {'optionIds': [str(option) for option in options]})

View File

@@ -1,6 +1,7 @@
from uuid import UUID
from itd.request import fetch
from itd.models.user import UserPrivacyData
def get_user(token: str, username: str):
@@ -26,6 +27,9 @@ def update_privacy(token: str, wall_closed: bool = False, private: bool = False)
data['isPrivate'] = private
return fetch(token, 'put', 'users/me/privacy', data)
def update_privacy_new(token: str, privacy: UserPrivacyData):
return fetch(token, 'put', 'users/me/privacy', privacy.to_dict())
def follow(token: str, username: str):
return fetch(token, 'post', f'users/{username}/follow')

View File

@@ -1,14 +1,8 @@
from warnings import deprecated
from itd.request import fetch
def verify(token: str, file_url: str):
# {"success":true,"request":{"id":"fc54e54f-8586-4d8c-809e-df93161f99da","userId":"9096a85b-c319-483e-8940-6921be427ad0","videoUrl":"https://943701f000610900cbe86b72234e451d.bckt.ru/videos/354f28a6-9ac7-48a6-879a-a454062b1d6b.mp4","status":"pending","rejectionReason":null,"reviewedBy":null,"reviewedAt":null,"createdAt":"2026-01-30T12:58:14.228Z","updatedAt":"2026-01-30T12:58:14.228Z"}}
return fetch(token, 'post', 'verification/submit', {'videoUrl': file_url})
@deprecated("verificate устарела используйте verify")
def verificate(token: str, file_url: str):
return verify(token, file_url)
def get_verification_status(token: str):
return fetch(token, 'get', 'verification/status')

195
itd/utils.py Normal file
View File

@@ -0,0 +1,195 @@
# новая версия от чат гпт. у меня самого не получилось сделать
import re
from itd.models.post import Span
from itd.enums import SpanType
class Tag:
def __init__(self, open: str, close: str, type: SpanType):
self.open = open
self.close = close
self.type = type
def _parse_spans(text: str, tags: list[Tag]) -> tuple[str, list[Span]]:
spans: list[Span] = []
stack: list[tuple[int, SpanType, int, int, str | None]] = []
clean_chars: list[str] = []
i = 0
while i < len(text):
# Проверка на экранирование
escaped = text[i] == '\\'
# Сначала проверяем закрывающие теги (с проверкой на экранирование)
closed = False
for idx, tag in enumerate(tags):
if text.startswith(tag.close, i) and stack and stack[-1][0] == idx:
if escaped:
# Экранированный закрывающий тег — выводим как текст (без слэша)
clean_chars.append(tag.close)
i += len(tag.close)
closed = True
break
_, span_type, offset, _, url = stack.pop()
spans.append(Span(length=len(clean_chars) - offset, offset=offset, type=span_type, url=url))
i += len(tag.close)
closed = True
break
if closed:
continue
# Затем проверяем открывающие теги
opened = False
for idx, tag in enumerate(tags):
if tag.type == SpanType.LINK:
if escaped:
match = re.match(tag.open, text[i+1:])
if match:
# Экранированный открывающий тег — выводим как текст (без слэша)
clean_chars.append(match.group(0))
i += 1 + match.end()
opened = True
break
else:
match = re.match(tag.open, text[i:])
if match:
url = match.group(1) if match.groups() else None
stack.append((idx, tag.type, len(clean_chars), i, url))
i += match.end()
opened = True
break
elif text.startswith(tag.open, i):
if escaped:
# Экранированный обычный тег — пропускаем, будет обработан в блоке is_escape
break
stack.append((idx, tag.type, len(clean_chars), i, None))
i += len(tag.open)
opened = True
break
if opened:
continue
# Если это слэш, проверяем, не экранирует ли он следующий тег
if escaped:
# Проверяем, следует ли за слэшем тег
is_escape = False
for tag in tags:
if tag.type == SpanType.LINK:
if re.match(tag.open, text[i+1:]):
is_escape = True
break
elif text.startswith(tag.open, i+1):
is_escape = True
break
# Проверяем закрывающие теги
if not is_escape:
for tag in tags:
if text.startswith(tag.close, i+1):
is_escape = True
break
if is_escape:
# Пропускаем слэш и выводим следующий тег как текст
i += 1
# Находим и выводим экранированный тег
skip = False
for tag in tags:
if tag.type == SpanType.LINK:
match = re.match(tag.open, text[i:])
if match:
clean_chars.append(match.group(0))
i += match.end()
skip = True
break
elif text.startswith(tag.open, i):
clean_chars.append(tag.open)
i += len(tag.open)
skip = True
break
if not skip:
# Проверяем закрывающие теги
for tag in tags:
if text.startswith(tag.close, i):
clean_chars.append(tag.close)
i += len(tag.close)
skip = True
break
continue
clean_chars.append(text[i])
i += 1
if stack:
_, last_type, _, raw_pos, _ = stack[-1]
raise ValueError(f'No closing tag for {last_type.value} at pos {raw_pos}')
spans.sort(key=lambda span: span.offset)
return ''.join(clean_chars), spans
def parse_html(text: str) -> tuple[str, list[Span]]:
return _parse_spans(
text,
[
Tag('<b>', '</b>', SpanType.BOLD),
Tag('<i>', '</i>', SpanType.ITALIC),
Tag('<s>', '</s>', SpanType.STRIKE),
Tag('<u>', '</u>', SpanType.UNDERLINE),
Tag('<code>', '</code>', SpanType.MONOSPACE),
Tag('<spoiler>', '</spoiler>', SpanType.SPOILER),
Tag(r'<a href="([^"]+)">', '</a>', SpanType.LINK),
Tag(r'<q>', '</q>', SpanType.QUOTE),
],
)
# версия от человека (не работает с вложенными тэгами)
# from re import finditer, Match
# from itd.models.post import Span
# from itd.enums import SpanType
# class Tag:
# def __init__(self, open: str, close: str, type: SpanType):
# self.open = open
# self.close = close
# self.type = type
# def raise_error(self, pos: int):
# raise ValueError(f'No closing tag for {self.type.value} at pos {pos - len(self.open)}')
# def to_span(self, start: int, end: int) -> Span:
# return Span(length=end - (start - len(self.open)), offset=start - len(self.open), type=self.type)
# def get_pos(self, match: Match[str], text: str, offset: int) -> tuple[int, int, str]:
# start = match.end() - offset
# text = text[:match.start() - offset] + text[start:]
# end = text.find(self.close, start)
# if end == -1:
# self.raise_error(start)
# return start - len(self.open), end, text[:end] + text[end + len(self.close):]
# def parse_html(text: str) -> tuple[str, list[Span]]:
# spans = []
# for tag in [
# Tag('<b>', '</b>', SpanType.BOLD),
# Tag('<i>', '</i>', SpanType.ITALIC),
# Tag('<s>', '</s>', SpanType.STRIKE),
# Tag('<u>', '</u>', SpanType.UNDERLINE),
# Tag('<code>', '</code>', SpanType.MONOSPACE),
# Tag('<spoiler>', '</spoiler>', SpanType.SPOILER),
# ]:
# offset = 0
# full_text = text
# for match in finditer(tag.open, full_text):
# start, end, text = tag.get_pos(match, text, offset)
# spans.append(tag.to_span(start, end))
# offset += len(tag.open) + len(tag.close)
# return text, spans

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "itd-sdk"
version = "1.1.0"
version = "1.2.0"
description = "ITD client for python"
readme = "README.md"
authors = [
@@ -12,6 +12,6 @@ authors = [
]
license = "MIT"
dependencies = [
"requests", "pydantic"
"requests", "pydantic", "sseclient-py"
]
requires-python = ">=3.9"

View File

@@ -2,10 +2,10 @@ from setuptools import setup, find_packages
setup(
name='itd-sdk',
version='1.1.0',
version='1.2.0',
packages=find_packages(),
install_requires=[
'requests', 'pydantic'
'requests', 'pydantic', 'sseclient-py'
],
python_requires=">=3.9"
)