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__/ __pycache__/
dist dist
itd_sdk.egg-info itd_sdk.egg-info
nowkie.gif nowkie.gif
g.gif

View File

@@ -1,5 +1,9 @@
# itd-sdk # itd-sdk
Клиент ITD для python Клиент 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()) print(c.get_me())
``` ```
<!--
> [!NOTE] > [!NOTE]
> Берите куки из запроса /auth/refresh. В остальных запросах нету refresh_token > Берите куки из запроса /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 ```python
from itd import ITDClient from itd import ITDClient
c = ITDClient(None, '...') c = ITDClient(None, 'Ваши cookies')
id = c.upload_file('любое-имя.png', open('реальное-имя-файла.png', 'rb'))['id'] c.update_banner('имя-файла.png')
c.update_profile(banner_id=id)
print('баннер обновлен') 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 ```python
from itd import ITDClient, StreamConnect, StreamNotification from itd import ITDClient, StreamConnect, StreamNotification
@@ -88,7 +126,7 @@ for event in c.stream_notifications():
- `wall_post` - пост на вашей стене - `wall_post` - пост на вашей стене
- `comment` - комментарий к посту - `comment` - комментарий к посту
- `reply` - ответ на комментарий - `reply` - ответ на комментарий
- `repost` - репост вашего поста - `repost` - репост вашего поста -->
### Кастомные запросы ### Кастомные запросы
@@ -103,17 +141,10 @@ fetch(c.token, 'метод', 'эндпоинт', {'данные': 'данные'
> [!NOTE] > [!NOTE]
> `xn--d1ah4a.com` - punycode от "итд.com" > `xn--d1ah4a.com` - punycode от "итд.com"
## Планы
- Форматированные сообщения об ошибках
- Логирование (через logging)
- Добавление ООП (отдеьные классы по типу User или Post вместо обычного JSON)
- Голосовые сообщения
## Прочее ## Прочее
Лицезия: [MIT](./LICENSE) Лицезия: [MIT](./LICENSE)
Идея (и часть эндпоинтов): https://github.com/FriceKa/ITD-SDK-js Идея (и часть эндпоинтов): 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 для различных сценариев. Эта папка содержит примеры использования 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 - [Основной 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 from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from itd import ITDClient, StreamConnect, StreamNotification from itd import ITDClient, StreamConnect
def main(): def main():
cookies = 'YOUR_COOKIES_HERE' cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE': if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies') print('! Укажите cookies в переменной cookies')
print(' См. examples/README.md для инструкций') print(' См. examples/README.md для инструкций')
return return
client = ITDClient(cookies=cookies) client = ITDClient(cookies=cookies)
print('-- Подключение к SSE...') print('-- Подключение к SSE...')
try: try:
for event in client.stream_notifications(): for event in client.stream_notifications():
if isinstance(event, StreamConnect): if isinstance(event, StreamConnect):
@@ -29,7 +29,7 @@ def main():
if event.preview: if event.preview:
preview = event.preview[:50] + '...' if len(event.preview) > 50 else event.preview preview = event.preview[:50] + '...' if len(event.preview) > 50 else event.preview
print(f' {preview}') print(f' {preview}')
except KeyboardInterrupt: except KeyboardInterrupt:
print(f'\n! Отключение...') print(f'\n! Отключение...')

View File

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

View File

@@ -5,29 +5,29 @@ import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent)) 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 from datetime import datetime
import json import json
def main(): def main():
cookies = 'YOUR_COOKIES_HERE' cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE': if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies') print('! Укажите cookies в переменной cookies')
print(' См. examples/README.md для инструкций') print(' См. examples/README.md для инструкций')
return return
client = ITDClient(cookies=cookies) client = ITDClient(cookies=cookies)
log_file = f'notifications_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log' log_file = f'notifications_{datetime.now().strftime("%Y%m%d_%H%M%S")}.log'
print(f'-- Подключение к SSE...') print(f'-- Подключение к SSE...')
print(f'-- Логирование в: {log_file}\n') print(f'-- Логирование в: {log_file}\n')
try: try:
with open(log_file, 'w', encoding='utf-8') as f: with open(log_file, 'w', encoding='utf-8') as f:
for event in client.stream_notifications(): for event in client.stream_notifications():
timestamp = datetime.now().isoformat() timestamp = datetime.now().isoformat()
if isinstance(event, StreamConnect): if isinstance(event, StreamConnect):
log_entry = { log_entry = {
'timestamp': timestamp, 'timestamp': timestamp,
@@ -49,10 +49,10 @@ def main():
'target_id': str(event.target_id) if event.target_id else None 'target_id': str(event.target_id) if event.target_id else None
} }
print(f'* {event.type.value}: {event.actor.username}') print(f'* {event.type.value}: {event.actor.username}')
f.write(json.dumps(log_entry, ensure_ascii=False) + '\n') f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
f.flush() f.flush()
except KeyboardInterrupt: except KeyboardInterrupt:
print(f'\n! Отключение... Лог сохранен в {log_file}') 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)) sys.path.insert(0, str(Path(__file__).parent.parent.parent))
import threading import threading
import time from itd import ITDClient, StreamConnect
from itd import ITDClient, StreamConnect, StreamNotification
def main(): def main():
cookies = 'YOUR_COOKIES_HERE' cookies = 'YOUR_COOKIES_HERE'
if cookies == 'YOUR_COOKIES_HERE': if cookies == 'YOUR_COOKIES_HERE':
print('! Укажите cookies в переменной cookies') print('! Укажите cookies в переменной cookies')
return return
client = ITDClient(cookies=cookies) client = ITDClient(cookies=cookies)
# Функция для прослушивания в отдельном потоке
def listen(): def listen():
print('! Начинаем прослушивание...') print('! Начинаем прослушивание...')
try: try:
@@ -29,17 +27,16 @@ def main():
print(f'🔔 {event.type.value}: {event.actor.username}') print(f'🔔 {event.type.value}: {event.actor.username}')
except Exception as e: except Exception as e:
print(f'! Ошибка: {e}') print(f'! Ошибка: {e}')
# В отдельном потоке
thread = threading.Thread(target=listen, daemon=True) thread = threading.Thread(target=listen, daemon=True)
thread.start() thread.start()
print('Прослушивание запущено. Нажмите Enter для остановки...') print('Прослушивание запущено. Нажмите Enter для остановки...')
input() input()
print('!! Останавливаем прослушивание...') print('!! Останавливаем прослушивание...')
client.stop_stream() client.stop_stream()
thread.join(timeout=5) thread.join(timeout=5)
print('! Остановлено') print('! Остановлено')

View File

@@ -1,20 +1,19 @@
# from warnings import deprecated
from uuid import UUID from uuid import UUID
from _io import BufferedReader from _io import BufferedReader
from typing import cast, Iterator from typing import cast, Iterator
from datetime import datetime from datetime import datetime
import json from json import JSONDecodeError, loads
import time from time import sleep
from requests.exceptions import ConnectionError, HTTPError from requests.exceptions import ConnectionError, HTTPError
from sseclient import SSEClient 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.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.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.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.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.reports import report
from itd.routes.search import search from itd.routes.search import search
from itd.routes.files import upload_file, get_file, delete_file 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.comment import Comment
from itd.models.notification import Notification 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.clan import Clan
from itd.models.hashtag import Hashtag 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.pagination import Pagination, PostsPagintaion, LikedPostsPagintaion
from itd.models.verification import Verification, VerificationStatus from itd.models.verification import Verification, VerificationStatus
from itd.models.report import NewReport from itd.models.report import NewReport
@@ -41,7 +40,7 @@ from itd.exceptions import (
NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned, NoCookie, NoAuthData, SamePassword, InvalidOldPassword, NotFound, ValidationError, UserBanned,
PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized, PendingRequestExists, Forbidden, UsernameTaken, CantFollowYourself, Unauthorized,
CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned, NoContent, CantRepostYourPost, AlreadyReposted, AlreadyReported, TooLarge, PinNotOwned, NoContent,
AlreadyFollowing, NotFoundOrForbidden AlreadyFollowing, NotFoundOrForbidden, OptionsNotBelong, NotMultipleChoice, EmptyOptions
) )
@@ -195,7 +194,7 @@ class Client:
@refresh_on_error @refresh_on_error
def update_privacy(self, wall_closed: bool = False, private: bool = False) -> UserPrivacy: def update_privacy(self, wall_closed: bool = False, private: bool = False) -> UserPrivacy:
"""Обновить настройки приватности """(УСТАРЕЛО! Используйте update_privacy_new) настройки приватности
Args: Args:
wall_closed (bool, optional): Закрыть стену. Defaults to False. wall_closed (bool, optional): Закрыть стену. Defaults to False.
@@ -209,6 +208,21 @@ class Client:
return UserPrivacy.model_validate(res.json()) 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 @refresh_on_error
def follow(self, username: str) -> int: def follow(self, username: str) -> int:
"""Подписаться на пользователя """Подписаться на пользователя
@@ -455,13 +469,13 @@ class Client:
@refresh_on_error @refresh_on_error
def get_replies(self, comment_id: UUID, limit: int = 50, page: int = 1, sort: str = 'oldest') -> tuple[list[Comment], Pagination]: def get_replies(self, comment_id: UUID, limit: int = 50, page: int = 1, sort: str = 'oldest') -> tuple[list[Comment], Pagination]:
"""Получить список комментариев """Получить список ответов на комментарий
Args: Args:
comment_id (UUID): UUID поста comment_id (UUID): UUID поста
limit (int, optional): Лимит. Defaults to 50. limit (int, optional): Лимит. Defaults to 50.
page (int, optional): Курсор (сколько пропустить). Defaults to 1. page (int, optional): Курсор (сколько пропустить). Defaults to 1.
sort (str, optional): Сортировка. Defaults to 'oldesr'. sort (str, optional): Сортировка. Defaults to 'oldest'.
Raises: Raises:
NotFound: Пост не найден NotFound: Пост не найден
@@ -616,7 +630,6 @@ class Client:
res = mark_all_as_read(self.token) res = mark_all_as_read(self.token)
res.raise_for_status() res.raise_for_status()
@refresh_on_error @refresh_on_error
def get_unread_notifications_count(self) -> int: def get_unread_notifications_count(self) -> int:
"""Получить количество непрочитанных уведомлений """Получить количество непрочитанных уведомлений
@@ -631,13 +644,15 @@ class Client:
@refresh_on_error @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: 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. 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: Raises:
NotFound: Пользователь не найден NotFound: Пользователь не найден
@@ -646,7 +661,8 @@ class Client:
Returns: Returns:
NewPost: Новый пост 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': if res.json().get('error', {}).get('code') == 'NOT_FOUND':
raise NotFound('Wall recipient') raise NotFound('Wall recipient')
if res.status_code == 422 and 'found' in res.json(): if res.status_code == 422 and 'found' in res.json():
@@ -655,6 +671,41 @@ class Client:
return NewPost.model_validate(res.json()) 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 @refresh_on_error
def get_posts(self, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR) -> tuple[list[Post], PostsPagintaion]: 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()) return File.model_validate(res.json())
# @deprecated # Этот декоратор появился в 3.13, а наша библиотека поддерживает с 3.9
def update_banner(self, name: str) -> UserProfileUpdate: def update_banner(self, name: str) -> UserProfileUpdate:
"""Обновить банер (шорткат из upload_file + update_profile) """[DEPRECATED] Обновить банер (шорткат из upload_file + update_profile)
Args: Args:
name (str): Имя файла name (str): Имя файла
@@ -1006,6 +1058,19 @@ class Client:
id = self.upload_file(name, cast(BufferedReader, open(name, 'rb'))).id id = self.upload_file(name, cast(BufferedReader, open(name, 'rb'))).id
return self.update_profile(banner_id=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 @refresh_on_error
def restore_post(self, post_id: UUID) -> None: def restore_post(self, post_id: UUID) -> None:
"""Восстановить удалённый пост """Восстановить удалённый пост
@@ -1088,62 +1153,63 @@ class Client:
return res.json()['pin'] return res.json()['pin']
@refresh_on_error @refresh_on_error
def stream_notifications(self) -> Iterator[StreamConnect | StreamNotification]: def stream_notifications(self) -> Iterator[StreamConnect | StreamNotification]:
"""Слушать SSE поток уведомлений """Слушать SSE поток уведомлений
Yields: Yields:
StreamConnect | StreamNotification: События подключения или уведомления StreamConnect | StreamNotification: События подключения или уведомления
Example: Example:
```python ```python
from itd import ITDClient from itd import ITDClient
client = ITDClient(cookies='refresh_token=...') client = ITDClient(cookies='refresh_token=...')
# Запуск прослушивания # Запуск прослушивания
for event in client.stream_notifications(): for event in client.stream_notifications():
if isinstance(event, StreamConnect): if isinstance(event, StreamConnect):
print(f'Подключено: {event.user_id}') print(f'Подключено: {event.user_id}')
else: else:
print(f'Уведомление: {event.type} от {event.actor.username}') print(f'Уведомление: {event.type} от {event.actor.username}')
# Остановка из другого потока или обработчика # Остановка из другого потока или обработчика
# client.stop_stream() # client.stop_stream()
``` ```
""" """
self._stream_active = True self._stream_active = True
while self._stream_active: while self._stream_active:
try: try:
response = stream_notifications(self.token) response = stream_notifications(self.token)
response.raise_for_status() response.raise_for_status()
client = SSEClient(response) client = SSEClient(response)
for event in client.events(): for event in client.events():
if not self._stream_active: if not self._stream_active:
response.close() response.close()
return return
try: try:
if not event.data or event.data.strip() == '': if not event.data or event.data.strip() == '':
continue continue
data = json.loads(event.data) data = loads(event.data)
if 'userId' in data and 'timestamp' in data and 'type' not in data: if 'userId' in data and 'timestamp' in data and 'type' not in data:
yield StreamConnect.model_validate(data) yield StreamConnect.model_validate(data)
else: else:
yield StreamNotification.model_validate(data) yield StreamNotification.model_validate(data)
except json.JSONDecodeError: except JSONDecodeError:
print(f'Не удалось распарсить сообщение: {event.data}') print(f'Не удалось распарсить сообщение: {event.data}')
continue continue
except Exception as e: except Exception as e:
print(f'Ошибка обработки события: {e}') print(f'Ошибка обработки события: {e}')
continue continue
except Unauthorized: except Unauthorized:
if self.cookies and self._stream_active: if self.cookies and self._stream_active:
print('Токен истек, обновляем...') print('Токен истек, обновляем...')
@@ -1155,27 +1221,27 @@ class Client:
if not self._stream_active: if not self._stream_active:
return return
print(f'Ошибка соединения: {e}, переподключение через 5 секунд...') print(f'Ошибка соединения: {e}, переподключение через 5 секунд...')
time.sleep(5) sleep(5)
continue continue
def stop_stream(self): def stop_stream(self):
"""Остановить прослушивание SSE потока """Остановить прослушивание SSE потока
Example: Example:
```python ```python
import threading import threading
from itd import ITDClient from itd import ITDClient
client = ITDClient(cookies='refresh_token=...') client = ITDClient(cookies='refresh_token=...')
# Запуск в отдельном потоке # Запуск в отдельном потоке
def listen(): def listen():
for event in client.stream_notifications(): for event in client.stream_notifications():
print(event) print(event)
thread = threading.Thread(target=listen) thread = threading.Thread(target=listen)
thread.start() thread.start()
# Остановка через 10 секунд # Остановка через 10 секунд
import time import time
time.sleep(10) time.sleep(10)
@@ -1183,4 +1249,5 @@ class Client:
thread.join() thread.join()
``` ```
""" """
print('stop event')
self._stream_active = False self._stream_active = False

View File

@@ -33,3 +33,21 @@ class AttachType(Enum):
class PostsTab(Enum): class PostsTab(Enum):
FOLLOWING = 'following' FOLLOWING = 'following'
POPULAR = 'popular' 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' return 'User banned'
class ValidationError(Exception): class ValidationError(Exception):
def __init__(self, name: str, value: str): # def __init__(self, name: str, value: str):
self.name = name # self.name = name
self.value = value # self.value = value
def __str__(self): def __str__(self):
return f'Failed validation on {self.name}: "{self.value}"' return 'Failed validation'# on {self.name}: "{self.value}"'
class PendingRequestExists(Exception): class PendingRequestExists(Exception):
def __str__(self): def __str__(self):
@@ -113,4 +113,24 @@ class NoContent(Exception):
class AlreadyFollowing(Exception): class AlreadyFollowing(Exception):
def __str__(self) -> str: 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 uuid import UUID
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from itd.enums import NotificationType, NotificationTargetType from itd.models.notification import Notification
from itd.models.user import UserNotification
class StreamConnect(BaseModel): class StreamConnect(BaseModel):
@@ -14,20 +11,7 @@ class StreamConnect(BaseModel):
timestamp: int timestamp: int
class StreamNotification(BaseModel): class StreamNotification(Notification):
"""Уведомление из SSE потока""" """Уведомление из 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') user_id: UUID = Field(alias='userId')
actor: UserNotification
read: bool = False
sound: bool = True sound: bool = True

View File

@@ -1,46 +1,104 @@
from uuid import UUID 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.user import UserPost, UserNewPost
from itd.models._text import TextObject from itd.models._text import TextObject
from itd.models.file import PostAttach from itd.models.file import PostAttach
from itd.models.comment import Comment 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') likes_count: int = Field(0, alias='likesCount')
comments_count: int = Field(0, alias='commentsCount') comments_count: int = Field(0, alias='commentsCount')
reposts_count: int = Field(0, alias='repostsCount') reposts_count: int = Field(0, alias='repostsCount')
views_count: int = Field(0, alias='viewsCount') views_count: int = Field(0, alias='viewsCount')
spans: list[Span] = []
class PostShort(_PostShort):
class _PostAuthor(_PostCounts):
author: UserPost author: UserPost
class OriginalPost(PostShort): class OriginalPost(_PostAuthor):
is_deleted: bool = Field(False, alias='isDeleted') is_deleted: bool = Field(False, alias='isDeleted')
class _Post(_PostShort): class _Post(_PostCounts):
is_liked: bool = Field(False, alias='isLiked') is_liked: bool = Field(False, alias='isLiked')
is_reposted: bool = Field(False, alias='isReposted') is_reposted: bool = Field(False, alias='isReposted')
is_viewed: bool = Field(False, alias='isViewed') is_viewed: bool = Field(False, alias='isViewed')
is_owner: bool = Field(False, alias='isOwner') is_owner: bool = Field(False, alias='isOwner')
is_pinned: bool = Field(False, alias='isPinned') # only for user wall
attachments: list[PostAttach] = [] attachments: list[PostAttach] = []
comments: list[Comment] = [] 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_id: UUID | None = Field(None, alias='wallRecipientId')
wall_recipient: UserPost | None = Field(None, alias='wallRecipient') wall_recipient: UserPost | None = Field(None, alias='wallRecipient')
class Post(_Post, PostShort): class Post(_Post, _PostAuthor):
pass poll: Poll | None = None
class NewPost(_Post): class NewPost(_Post):
author: UserNewPost author: UserNewPost
poll: NewPoll | None = None

View File

@@ -4,13 +4,41 @@ from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from itd.models.pin import ShortPin 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 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): class UserProfileUpdate(BaseModel):
@@ -51,7 +79,7 @@ class UserSearch(UserFollower, UserWhoToFollow):
pass pass
class User(UserSearch, UserPrivacy): class User(UserSearch, _UserPrivacy):
banner: str | None = None banner: str | None = None
bio: str | None = None bio: str | None = None
pinned_post_id: UUID | None = Field(None, alias='pinnedPostId') 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 is_followed: bool | None = Field(None, alias='isFollowedBy') # none for me
created_at: datetime = Field(alias='createdAt') 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 import Session
from requests.exceptions import JSONDecodeError 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() 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)) raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0))
if res.json().get('error', {}).get('code') == 'UNAUTHORIZED': if res.json().get('error', {}).get('code') == 'UNAUTHORIZED':
raise 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): except (JSONDecodeError, AttributeError):
pass # todo pass # todo

View File

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

View File

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

View File

@@ -3,13 +3,18 @@ from uuid import UUID
from itd.request import fetch from itd.request import fetch
from itd.enums import PostsTab 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] = []): 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} data: dict = {'content': content or ''}
if spans:
data['spans'] = spans
if wall_recipient_id: if wall_recipient_id:
data['wallRecipientId'] = str(wall_recipient_id) data['wallRecipientId'] = str(wall_recipient_id)
if attachment_ids: if attachment_ids:
data['attachmentIds'] = list(map(str, attachment_ids)) data['attachmentIds'] = list(map(str, attachment_ids))
if poll:
data['poll'] = poll.model_dump()
return fetch(token, 'post', 'posts', data) 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): 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}) return fetch(token, 'get', f'posts/user/{username_or_id}', {'limit': limit, 'cursor': cursor})
def restore_post(token: str, post_id: UUID): def restore_post(token: str, id: UUID):
return fetch(token, "post", f"posts/{post_id}/restore",) return fetch(token, "post", f"posts/{id}/restore",)
def like_post(token: str, post_id: UUID): def like_post(token: str, id: UUID):
return fetch(token, "post", f"posts/{post_id}/like") return fetch(token, "post", f"posts/{id}/like")
def unlike_post(token: str, post_id: UUID): def unlike_post(token: str, id: UUID):
return fetch(token, "delete", f"posts/{post_id}/like") 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 uuid import UUID
from itd.request import fetch from itd.request import fetch
from itd.models.user import UserPrivacyData
def get_user(token: str, username: str): 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 data['isPrivate'] = private
return fetch(token, 'put', 'users/me/privacy', data) 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): def follow(token: str, username: str):
return fetch(token, 'post', f'users/{username}/follow') return fetch(token, 'post', f'users/{username}/follow')

View File

@@ -1,14 +1,8 @@
from warnings import deprecated
from itd.request import fetch from itd.request import fetch
def verify(token: str, file_url: str): 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"}} # {"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}) 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): def get_verification_status(token: str):
return fetch(token, 'get', 'verification/status') 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] [project]
name = "itd-sdk" name = "itd-sdk"
version = "1.1.0" version = "1.2.0"
description = "ITD client for python" description = "ITD client for python"
readme = "README.md" readme = "README.md"
authors = [ authors = [
@@ -12,6 +12,6 @@ authors = [
] ]
license = "MIT" license = "MIT"
dependencies = [ dependencies = [
"requests", "pydantic" "requests", "pydantic", "sseclient-py"
] ]
requires-python = ">=3.9" requires-python = ">=3.9"

View File

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