From 13365fc23aae37625b0c131ca46a848d8f14398d Mon Sep 17 00:00:00 2001 From: Vasily Domakov Date: Mon, 9 Feb 2026 23:17:42 +0300 Subject: [PATCH 01/20] #11 Add model for events --- itd/models/event.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 itd/models/event.py diff --git a/itd/models/event.py b/itd/models/event.py new file mode 100644 index 0000000..775bd8c --- /dev/null +++ b/itd/models/event.py @@ -0,0 +1,33 @@ +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 + + +class StreamConnect(BaseModel): + """Событие подключения к SSE потоку""" + user_id: UUID = Field(alias='userId') + timestamp: int + + +class StreamNotification(BaseModel): + """Уведомление из 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 From f2e18e08c062b11622404627c6e794cd168dffb7 Mon Sep 17 00:00:00 2001 From: Vasily Domakov Date: Mon, 9 Feb 2026 23:21:01 +0300 Subject: [PATCH 02/20] #11 Added SSE listening --- itd/__init__.py | 5 +- itd/client.py | 108 +++++++++++++++++++++++++++++++++++- itd/models/__init__.py | 3 + itd/request.py | 11 ++++ itd/routes/notifications.py | 12 +++- requirements.txt | 3 +- 6 files changed, 135 insertions(+), 7 deletions(-) diff --git a/itd/__init__.py b/itd/__init__.py index e33c29f..5d71f2a 100644 --- a/itd/__init__.py +++ b/itd/__init__.py @@ -1 +1,4 @@ -from itd.client import Client as ITDClient \ No newline at end of file +from itd.client import Client as ITDClient +from itd.models.event import StreamConnect, StreamNotification + +__all__ = ['ITDClient', 'StreamConnect', 'StreamNotification'] \ No newline at end of file diff --git a/itd/client.py b/itd/client.py index c63b8b1..7d36255 100644 --- a/itd/client.py +++ b/itd/client.py @@ -1,16 +1,19 @@ # from warnings import deprecated from uuid import UUID from _io import BufferedReader -from typing import cast +from typing import cast, Iterator from datetime import datetime +import json +import time 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.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 +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.reports import report from itd.routes.search import search @@ -30,6 +33,7 @@ from itd.models.verification import Verification, VerificationStatus from itd.models.report import NewReport from itd.models.file import File from itd.models.pin import Pin +from itd.models.event import StreamConnect, StreamNotification from itd.enums import PostsTab, ReportTargetType, ReportTargetReason from itd.request import set_cookies @@ -57,6 +61,7 @@ def refresh_on_error(func): class Client: def __init__(self, token: str | None = None, cookies: str | None = None): self.cookies = cookies + self._stream_active = False # Флаг для остановки stream_notifications if token: self.token = token.replace('Bearer ', '') @@ -1081,4 +1086,101 @@ class Client: raise PinNotOwned(slug) res.raise_for_status() - return res.json()['pin'] \ No newline at end of file + 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) + + 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: + print(f'Не удалось распарсить сообщение: {event.data}') + continue + except Exception as e: + print(f'Ошибка обработки события: {e}') + continue + + except Unauthorized: + if self.cookies and self._stream_active: + print('Токен истек, обновляем...') + self.refresh_auth() + continue + else: + raise + except Exception as e: + if not self._stream_active: + return + print(f'Ошибка соединения: {e}, переподключение через 5 секунд...') + time.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) + client.stop_stream() + thread.join() + ``` + """ + self._stream_active = False \ No newline at end of file diff --git a/itd/models/__init__.py b/itd/models/__init__.py index e69de29..8acef9a 100644 --- a/itd/models/__init__.py +++ b/itd/models/__init__.py @@ -0,0 +1,3 @@ +from itd.models.event import StreamConnect, StreamNotification + +__all__ = ['StreamConnect', 'StreamNotification'] diff --git a/itd/request.py b/itd/request.py index e052b7f..c634258 100644 --- a/itd/request.py +++ b/itd/request.py @@ -47,6 +47,17 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, return res +def fetch_stream(token: str, url: str): + """Fetch для SSE streaming запросов""" + base = f'https://xn--d1ah4a.com/api/{url}' + headers = { + "Accept": "text/event-stream", + "Authorization": 'Bearer ' + token, + "Cache-Control": "no-cache" + } + return s.get(base, headers=headers, stream=True, timeout=None) + + def set_cookies(cookies: str): for cookie in cookies.split('; '): s.cookies.set(cookie.split('=')[0], cookie.split('=')[-1], path='/', domain='xn--d1ah4a.com.com') diff --git a/itd/routes/notifications.py b/itd/routes/notifications.py index de0a741..a13111d 100644 --- a/itd/routes/notifications.py +++ b/itd/routes/notifications.py @@ -1,6 +1,6 @@ from uuid import UUID -from itd.request import fetch +from itd.request import fetch, fetch_stream def get_notifications(token: str, limit: int = 20, offset: int = 0): return fetch(token, 'get', 'notifications', {'limit': limit, 'offset': offset}) @@ -12,4 +12,12 @@ def mark_all_as_read(token: str): return fetch(token, 'post', f'notifications/read-all') def get_unread_notifications_count(token: str): - return fetch(token, 'get', 'notifications/count') \ No newline at end of file + return fetch(token, 'get', 'notifications/count') + +def stream_notifications(token: str): + """Получить SSE поток уведомлений + + Returns: + Response: Streaming response для SSE + """ + return fetch_stream(token, 'notifications/stream') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 541740e..5b7ef1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pydantic==2.11.9 -requests==2.32.3 \ No newline at end of file +requests==2.32.3 +sseclient-py==1.8.0 \ No newline at end of file From a3a3c012ff6be5de3bb64f22b4b9e7e6f26e78e9 Mon Sep 17 00:00:00 2001 From: Vasily Domakov Date: Mon, 9 Feb 2026 23:22:30 +0300 Subject: [PATCH 03/20] #12 Added examples --- README.md | 26 +++++++++ examples/README.md | 73 ++++++++++++++++++++++++ examples/stream/README.md | 75 +++++++++++++++++++++++++ examples/stream/basic_stream.py | 37 ++++++++++++ examples/stream/filter_notifications.py | 54 ++++++++++++++++++ examples/stream/notification_logger.py | 60 ++++++++++++++++++++ examples/stream/stop_stream.py | 47 ++++++++++++++++ 7 files changed, 372 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/stream/README.md create mode 100644 examples/stream/basic_stream.py create mode 100644 examples/stream/filter_notifications.py create mode 100644 examples/stream/notification_logger.py create mode 100644 examples/stream/stop_stream.py diff --git a/README.md b/README.md index e598fb4..309d491 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,32 @@ c.create_post('тест1') # создание постов # итд ``` +### SSE - прослушивание уведомлений в реальном времени + +```python +from itd import ITDClient, StreamConnect, StreamNotification + +# Используйте cookies для автоматического обновления токена +c = ITDClient(cookies='refresh_token=...; __ddg1_=...; is_auth=1') + +for event in c.stream_notifications(): + if isinstance(event, StreamConnect): + print(f'! Подключено к SSE: {event.user_id}') + elif isinstance(event, StreamNotification): + print(f'-- {event.type.value}: {event.actor.display_name} (@{event.actor.username})') +``` + +> [!NOTE] +> SSE автоматически переподключается при истечении токена + +Типы уведомлений: +- `like` - лайк на пост +- `follow` - новый подписчик +- `wall_post` - пост на вашей стене +- `comment` - комментарий к посту +- `reply` - ответ на комментарий +- `repost` - репост вашего поста + ### Кастомные запросы ```python diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..870e87e --- /dev/null +++ b/examples/README.md @@ -0,0 +1,73 @@ +# Примеры использования 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 с подробностями + +## Помощь + +Если примеры не работают: + +1. Проверьте, что cookies актуальные (не истекли) +2. Убедитесь, что установлены все зависимости +3. Проверьте формат cookies (должен содержать `refresh_token=`) +4. Используйте Python 3.13+ (для поддержки `deprecated`) diff --git a/examples/stream/README.md b/examples/stream/README.md new file mode 100644 index 0000000..99a7d41 --- /dev/null +++ b/examples/stream/README.md @@ -0,0 +1,75 @@ +# Stream - Прослушивание уведомлений + +Примеры работы с SSE (Server-Sent Events) потоком уведомлений в реальном времени. + +## Подготовка + +1. Установите зависимости: +```bash +pip install -r ../../requirements.txt +``` + +2. Получите cookies с `refresh_token` (см. [главный README](../README.md)) + +3. Запускайте примеры из корня проекта или из папки `examples/stream/` + +## Примеры + +### basic_stream.py +Базовое прослушивание всех уведомлений. + +```bash +python basic_stream.py +``` + +Показывает все входящие уведомления в реальном времени. + +### stop_stream.py +Программная остановка потока через `client.stop_stream()`. + +```bash +python stop_stream.py +``` + +Полезно для интеграции в многопоточные приложения. + +### filter_notifications.py +Фильтрация уведомлений по типу. + +```bash +python filter_notifications.py +``` + +Показывает только выбранные типы (like, follow, comment). Настраивается через `SHOW_TYPES`. + +### notification_logger.py +Логирование всех уведомлений в JSON файл. + +```bash +python notification_logger.py +``` + +Создает файл `notifications_YYYYMMDD_HHMMSS.log` с полной историей событий. + +## Типы уведомлений + +- **like** - Лайк на пост +- **follow** - Новый подписчик +- **wall_post** - Пост на вашей стене +- **comment** - Комментарий к посту +- **reply** - Ответ на комментарий +- **repost** - Репост вашего поста + +## Особенности + +- ✅ Автоматическое переподключение при разрыве +- ✅ Автоматическое обновление токена (при использовании cookies) +- ✅ Обработка всех типов уведомлений +- ✅ Graceful shutdown по Ctrl+C + +## API Reference + +Подробная документация по методам и моделям: +- [Основной README](../../README.md) - Общая информация об SDK +- [itd/client.py](../../itd/client.py) - Метод `stream_notifications()` +- [itd/models/event.py](../../itd/models/event.py) - Модели `StreamConnect` и `StreamNotification` diff --git a/examples/stream/basic_stream.py b/examples/stream/basic_stream.py new file mode 100644 index 0000000..6928ea1 --- /dev/null +++ b/examples/stream/basic_stream.py @@ -0,0 +1,37 @@ +""" +Базовый пример прослушивания SSE потока уведомлений +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from itd import ITDClient, StreamConnect, StreamNotification + +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): + print(f'-- Подключено! User ID: {event.user_id}') + print('-- Ожидание уведомлений...\n') + else: + print(f'* {event.type.value}: {event.actor.username}') + if event.preview: + preview = event.preview[:50] + '...' if len(event.preview) > 50 else event.preview + print(f' {preview}') + + except KeyboardInterrupt: + print(f'\n! Отключение...') + +if __name__ == '__main__': + main() diff --git a/examples/stream/filter_notifications.py b/examples/stream/filter_notifications.py new file mode 100644 index 0000000..f26063e --- /dev/null +++ b/examples/stream/filter_notifications.py @@ -0,0 +1,54 @@ +""" +Пример фильтрации уведомлений по типу +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from itd import ITDClient, StreamConnect, StreamNotification +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, + } + + 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! Отключение...') + +if __name__ == '__main__': + main() diff --git a/examples/stream/notification_logger.py b/examples/stream/notification_logger.py new file mode 100644 index 0000000..fb758b0 --- /dev/null +++ b/examples/stream/notification_logger.py @@ -0,0 +1,60 @@ +""" +Пример логирования уведомлений в файл +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from itd import ITDClient, StreamConnect, StreamNotification +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, + 'type': 'connect', + 'user_id': str(event.user_id) + } + print(f'-- Подключено! User ID: {event.user_id}') + else: + log_entry = { + 'timestamp': timestamp, + 'type': event.type.value, + 'id': str(event.id), + 'actor': { + 'username': event.actor.username, + 'display_name': event.actor.display_name + }, + 'preview': event.preview, + 'target_type': event.target_type.value if event.target_type else None, + '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}') + +if __name__ == '__main__': + main() diff --git a/examples/stream/stop_stream.py b/examples/stream/stop_stream.py new file mode 100644 index 0000000..ef46cf2 --- /dev/null +++ b/examples/stream/stop_stream.py @@ -0,0 +1,47 @@ +""" +Пример остановки SSE потока из кода +""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import threading +import time +from itd import ITDClient, StreamConnect, StreamNotification + +def main(): + cookies = 'YOUR_COOKIES_HERE' + + if cookies == 'YOUR_COOKIES_HERE': + print('! Укажите cookies в переменной cookies') + return + + client = ITDClient(cookies=cookies) + + # Функция для прослушивания в отдельном потоке + def listen(): + print('! Начинаем прослушивание...') + try: + for event in client.stream_notifications(): + if isinstance(event, StreamConnect): + print(f'-- Подключено! User ID: {event.user_id}') + else: + 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('! Остановлено') + +if __name__ == '__main__': + main() From 3ff5b90380f1af7b817cdfd24f9d9a738b1e1256 Mon Sep 17 00:00:00 2001 From: firedotguy <158167689+firedotguy@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:13:47 +0300 Subject: [PATCH 04/20] docs: update banner updare example --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 309d491..2106185 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,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('баннер обновлен') ``` From 337a1eb17bc01f88bdeeb7cf99094551784eaa79 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Tue, 10 Feb 2026 14:42:56 +0300 Subject: [PATCH 05/20] fix: remove deprecated functions --- itd/routes/hashtags.py | 1 - itd/routes/verification.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/itd/routes/hashtags.py b/itd/routes/hashtags.py index 83f3a93..057dc7a 100644 --- a/itd/routes/hashtags.py +++ b/itd/routes/hashtags.py @@ -1,4 +1,3 @@ -from warnings import deprecated from uuid import UUID from itd.request import fetch diff --git a/itd/routes/verification.py b/itd/routes/verification.py index ec643d5..df00509 100644 --- a/itd/routes/verification.py +++ b/itd/routes/verification.py @@ -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') \ No newline at end of file From d49fb2d4cb66006128eb3e7c95f29cbe7e47edaa Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:27:58 +0400 Subject: [PATCH 06/20] =?UTF-8?q?feat(scripts):=20=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B8=20=D1=81=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/README.md | 7 ++++ scripts/itd-change-banner.py | 78 +++++++++++++++++++++++++++++++++++ scripts/itd-create-post.py | 80 ++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/itd-change-banner.py create mode 100644 scripts/itd-create-post.py diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..7a5dbff --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,7 @@ +# Коллекция скриптов от [@kilyabin](https://github.com/kilyabin) + +Пока всего два скрипта, однако будет дополняться + +Работают через аргументы командной строки (например, `--file` или `--text`) + +Есть помощь при аргументе `-h`, потому - разберетесь diff --git a/scripts/itd-change-banner.py b/scripts/itd-change-banner.py new file mode 100644 index 0000000..13e3463 --- /dev/null +++ b/scripts/itd-change-banner.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 + +import argparse +import os +import sys +from itd import ITDClient + +def main(): + parser = argparse.ArgumentParser( + description='Upload image and set it as profile banner' + ) + + parser.add_argument( + '--token', + default=os.getenv('ITD_TOKEN'), + help='API token (or ITD_TOKEN env var)' + ) + + parser.add_argument( + '--file', + required=True, + help='Path to image file' + ) + + parser.add_argument( + '--name', + help='Filename on server (default: local filename)' + ) + + args = parser.parse_args() + + if not args.token: + print('❌ Токен не задан (--token или ITD_TOKEN)', file=sys.stderr) + sys.exit(1) + + file_path = args.file + + if not os.path.isfile(file_path): + print(f'❌ Файл не найден: {file_path}', file=sys.stderr) + sys.exit(1) + + server_name = args.name or os.path.basename(file_path) + + try: + client = ITDClient(None, args.token) + + # Загружаем файл + with open(file_path, 'rb') as f: + response = client.upload_file(server_name, f) + + # Проверяем, что получили id + file_id = getattr(response, 'id', None) + if file_id is None: + print('❌ Не удалось получить id файла') + print(response) + sys.exit(1) + + # Преобразуем UUID в строку + file_id_str = str(file_id) + + # Обновляем баннер + update_resp = client.update_profile(banner_id=file_id_str) + + print('✅ Баннер обновлён!') + print('📄 Информация о файле:') + print(f' id: {file_id_str}') + print(f' filename: {response.filename}') + print(f' mime_type: {response.mime_type}') + print(f' size: {response.size} bytes') + print(f' url: {response.url}') + + except Exception as e: + print('❌ Произошла ошибка:', e, file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/scripts/itd-create-post.py b/scripts/itd-create-post.py new file mode 100644 index 0000000..349f17e --- /dev/null +++ b/scripts/itd-create-post.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 + +import argparse +import os +import sys +from itd import ITDClient + +def main(): + parser = argparse.ArgumentParser( + description='Create a post on ITD via CLI' + ) + + parser.add_argument( + '--token', + default=os.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)', file=sys.stderr) + sys.exit(1) + + try: + client = ITDClient(None, args.token) + + file_id = None + if args.file: + if not os.path.isfile(args.file): + print(f'❌ File not found: {args.file}', file=sys.stderr) + sys.exit(1) + + server_name = args.filename or os.path.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') + sys.exit(1) + print(f'✅ File uploaded: {response.filename} (id={file_id})') + + # Создаём пост с правильным аргументом 'content' + if file_id: + post_resp = client.create_post(content=args.text, file_ids=[file_id]) + else: + post_resp = client.create_post(content=args.text) + + # Вывод результата + print('✅ Post created successfully!') + print(f' id: {post_resp.id}') + if hasattr(post_resp, 'url'): + print(f' url: {post_resp.url}') + print(f' text: {args.text}') + if file_id: + print(f' attached file id: {file_id}') + + except Exception as e: + print('❌ Error:', e, file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() From 51518ce0d7c190a7fa19345c6189266ed8b78357 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Tue, 10 Feb 2026 15:49:06 +0300 Subject: [PATCH 07/20] fix: stylize examples --- itd/client.py | 18 ++++++++++-- scripts/itd-change-banner.py | 54 +++++++++++------------------------- scripts/itd-create-post.py | 34 +++++++++++------------ 3 files changed, 48 insertions(+), 58 deletions(-) diff --git a/itd/client.py b/itd/client.py index 7d36255..f238984 100644 --- a/itd/client.py +++ b/itd/client.py @@ -1,4 +1,3 @@ -# from warnings import deprecated from uuid import UUID from _io import BufferedReader from typing import cast, Iterator @@ -994,8 +993,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 +1006,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,6 +1101,7 @@ class Client: return res.json()['pin'] + @refresh_on_error def stream_notifications(self) -> Iterator[StreamConnect | StreamNotification]: """Слушать SSE поток уведомлений diff --git a/scripts/itd-change-banner.py b/scripts/itd-change-banner.py index 13e3463..36b86bf 100644 --- a/scripts/itd-change-banner.py +++ b/scripts/itd-change-banner.py @@ -1,18 +1,19 @@ #!/usr/bin/env python3 - -import argparse -import os +from argparse import ArgumentParser +from os import getenv +from os.path import isfile import sys + from itd import ITDClient def main(): - parser = argparse.ArgumentParser( + parser = ArgumentParser( description='Upload image and set it as profile banner' ) parser.add_argument( '--token', - default=os.getenv('ITD_TOKEN'), + default=getenv('ITD_TOKEN'), help='API token (or ITD_TOKEN env var)' ) @@ -22,56 +23,33 @@ def main(): help='Path to image file' ) - parser.add_argument( - '--name', - help='Filename on server (default: local filename)' - ) - args = parser.parse_args() if not args.token: print('❌ Токен не задан (--token или ITD_TOKEN)', file=sys.stderr) - sys.exit(1) + quit() file_path = args.file - if not os.path.isfile(file_path): + if not isfile(file_path): print(f'❌ Файл не найден: {file_path}', file=sys.stderr) - sys.exit(1) - - server_name = args.name or os.path.basename(file_path) + quit() try: client = ITDClient(None, args.token) - - # Загружаем файл - with open(file_path, 'rb') as f: - response = client.upload_file(server_name, f) - - # Проверяем, что получили id - file_id = getattr(response, 'id', None) - if file_id is None: - print('❌ Не удалось получить id файла') - print(response) - sys.exit(1) - - # Преобразуем UUID в строку - file_id_str = str(file_id) - - # Обновляем баннер - update_resp = client.update_profile(banner_id=file_id_str) + data, _ = client.update_banner_new(file_path) print('✅ Баннер обновлён!') print('📄 Информация о файле:') - print(f' id: {file_id_str}') - print(f' filename: {response.filename}') - print(f' mime_type: {response.mime_type}') - print(f' size: {response.size} bytes') - print(f' url: {response.url}') + 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) - sys.exit(1) + quit() if __name__ == '__main__': diff --git a/scripts/itd-create-post.py b/scripts/itd-create-post.py index 349f17e..c8c9739 100644 --- a/scripts/itd-create-post.py +++ b/scripts/itd-create-post.py @@ -1,18 +1,19 @@ #!/usr/bin/env python3 +from uuid import UUID +from argparse import ArgumentParser +from os import getenv +from os.path import isfile, basename -import argparse -import os -import sys from itd import ITDClient def main(): - parser = argparse.ArgumentParser( + parser = ArgumentParser( description='Create a post on ITD via CLI' ) parser.add_argument( '--token', - default=os.getenv('ITD_TOKEN'), + default=getenv('ITD_TOKEN'), help='Refresh token (or set ITD_TOKEN environment variable)' ) @@ -35,46 +36,43 @@ def main(): args = parser.parse_args() if not args.token: - print('❌ Token not provided (--token or ITD_TOKEN)', file=sys.stderr) - sys.exit(1) + print('❌ Token not provided (--token or ITD_TOKEN)') + quit() try: client = ITDClient(None, args.token) file_id = None if args.file: - if not os.path.isfile(args.file): - print(f'❌ File not found: {args.file}', file=sys.stderr) - sys.exit(1) + if not isfile(args.file): + print(f'❌ File not found: {args.file}') + quit() - server_name = args.filename or os.path.basename(args.file) + 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') - sys.exit(1) + quit() print(f'✅ File uploaded: {response.filename} (id={file_id})') # Создаём пост с правильным аргументом 'content' if file_id: - post_resp = client.create_post(content=args.text, file_ids=[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}') - if hasattr(post_resp, 'url'): - print(f' url: {post_resp.url}') print(f' text: {args.text}') if file_id: print(f' attached file id: {file_id}') except Exception as e: - print('❌ Error:', e, file=sys.stderr) - sys.exit(1) + print('❌ Error:', e) + quit() if __name__ == '__main__': main() From 8e8b0b3bb90bf209cd4146280ff9b4df641abab3 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Tue, 10 Feb 2026 17:53:48 +0300 Subject: [PATCH 08/20] chore: stylize sse code; fix: add sseclient-py to requirements --- itd/client.py | 49 +++++++++++++++++++------------------ itd/models/event.py | 20 ++------------- itd/routes/notifications.py | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/itd/client.py b/itd/client.py index f238984..62f19bb 100644 --- a/itd/client.py +++ b/itd/client.py @@ -2,8 +2,8 @@ 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 @@ -1105,59 +1105,59 @@ class Client: @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('Токен истек, обновляем...') @@ -1169,27 +1169,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) @@ -1197,4 +1197,5 @@ class Client: thread.join() ``` """ + print('stop event') self._stream_active = False \ No newline at end of file diff --git a/itd/models/event.py b/itd/models/event.py index 775bd8c..fa4ef2d 100644 --- a/itd/models/event.py +++ b/itd/models/event.py @@ -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 diff --git a/itd/routes/notifications.py b/itd/routes/notifications.py index a13111d..aa91325 100644 --- a/itd/routes/notifications.py +++ b/itd/routes/notifications.py @@ -16,7 +16,7 @@ def get_unread_notifications_count(token: str): def stream_notifications(token: str): """Получить SSE поток уведомлений - + Returns: Response: Streaming response для SSE """ diff --git a/pyproject.toml b/pyproject.toml index d5390c8..49e7d79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,6 @@ authors = [ ] license = "MIT" dependencies = [ - "requests", "pydantic" + "requests", "pydantic", "sseclient-py" ] requires-python = ">=3.9" diff --git a/setup.py b/setup.py index 8f007b7..6b0f401 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( version='1.1.0', packages=find_packages(), install_requires=[ - 'requests', 'pydantic' + 'requests', 'pydantic', 'sseclient-py' ], python_requires=">=3.9" ) From aad83b55d5cdac6858db0f4b046f2f1a73b5acf0 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Tue, 10 Feb 2026 18:28:16 +0300 Subject: [PATCH 09/20] chore: update version; move scripts into examples folder --- README.md | 19 ++++++- examples/README.md | 56 ------------------- {scripts => examples/kilyabin}/README.md | 0 .../kilyabin}/itd-change-banner.py | 0 .../kilyabin}/itd-create-post.py | 0 examples/stream/basic_stream.py | 12 ++-- examples/stream/filter_notifications.py | 25 ++++----- examples/stream/notification_logger.py | 16 +++--- examples/stream/stop_stream.py | 21 +++---- pyproject.toml | 2 +- setup.py | 2 +- 11 files changed, 54 insertions(+), 99 deletions(-) rename {scripts => examples/kilyabin}/README.md (100%) rename {scripts => examples/kilyabin}/itd-change-banner.py (100%) rename {scripts => examples/kilyabin}/itd-create-post.py (100%) diff --git a/README.md b/README.md index 2106185..85607e3 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,25 @@ c = ITDClient('TOKEN', 'refresh_token=...; __ddg1_=...; __ddgid_=...; is_auth=1; print(c.get_me()) ``` - + + +## Получение 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) --- ### Скрипт на обновление имени diff --git a/examples/README.md b/examples/README.md index 870e87e..055735c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/scripts/README.md b/examples/kilyabin/README.md similarity index 100% rename from scripts/README.md rename to examples/kilyabin/README.md diff --git a/scripts/itd-change-banner.py b/examples/kilyabin/itd-change-banner.py similarity index 100% rename from scripts/itd-change-banner.py rename to examples/kilyabin/itd-change-banner.py diff --git a/scripts/itd-create-post.py b/examples/kilyabin/itd-create-post.py similarity index 100% rename from scripts/itd-create-post.py rename to examples/kilyabin/itd-create-post.py diff --git a/examples/stream/basic_stream.py b/examples/stream/basic_stream.py index 6928ea1..cbffb0d 100644 --- a/examples/stream/basic_stream.py +++ b/examples/stream/basic_stream.py @@ -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! Отключение...') diff --git a/examples/stream/filter_notifications.py b/examples/stream/filter_notifications.py index f26063e..10f844a 100644 --- a/examples/stream/filter_notifications.py +++ b/examples/stream/filter_notifications.py @@ -5,48 +5,47 @@ 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! Отключение...') diff --git a/examples/stream/notification_logger.py b/examples/stream/notification_logger.py index fb758b0..9e0bd0b 100644 --- a/examples/stream/notification_logger.py +++ b/examples/stream/notification_logger.py @@ -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}') diff --git a/examples/stream/stop_stream.py b/examples/stream/stop_stream.py index ef46cf2..91c29b9 100644 --- a/examples/stream/stop_stream.py +++ b/examples/stream/stop_stream.py @@ -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('! Остановлено') diff --git a/pyproject.toml b/pyproject.toml index 49e7d79..1d718a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/setup.py b/setup.py index 6b0f401..b04f3fe 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ 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', 'sseclient-py' From 7cc343dab5938cb6be548f5f2283d4be5fd010b7 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Tue, 10 Feb 2026 18:39:43 +0300 Subject: [PATCH 10/20] docs: remove plans; remove sse example --- README.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 85607e3..6d0a4c2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ print(c.get_me()) > Берите куки из запроса /auth/refresh. В остальных запросах нету refresh_token > ![cookie](cookie-screen.png) --> -## Получение cookies +### Получение cookies Для получения access_token требуются cookies с `refresh_token`. Как их получить: @@ -33,7 +33,7 @@ print(c.get_me()) 4. Обновите страницу 5. Найдите запрос к `/auth/refresh` 6. Скопируйте значение **Cookie** из Request Headers -> Пример: `refresh_token=123123A67BCdEfGG; is_auth=1` +> Пример: `refresh_token=123123A67BCdEfGG; is_auth=1` > В cookies также могут присутствовать значения типа `__ddgX__` (DDoS-Guard cookies) или `_ym_XXXX` (`X` - любое число или буква). Они необязательные и их наличие не влияет на результат ![cookie](cookie-screen.png) @@ -78,7 +78,7 @@ c.create_post('тест1') # создание постов # итд ``` -### SSE - прослушивание уведомлений в реальном времени + ### Кастомные запросы @@ -117,16 +117,9 @@ fetch(c.token, 'метод', 'эндпоинт', {'данные': 'данные' > [!NOTE] > `xn--d1ah4a.com` - punycode от "итд.com" -## Планы - - - Форматированные сообщения об ошибках - - Логирование (через logging) - - Добавление ООП (отдеьные классы по типу User или Post вместо обычного JSON) - - Голосовые сообщения - ## Прочее -Лицезия: [MIT](./LICENSE) +Лицезия: [MIT](./LICENSE) Идея (и часть эндпоинтов): https://github.com/FriceKa/ITD-SDK-js - По сути этот проект является реворком, просто на другом языке From c1042d32aea5a2bd8adaf3a87753bad0b5f926f4 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Thu, 12 Feb 2026 18:25:25 +0300 Subject: [PATCH 11/20] fix: add BannedAccount error --- .gitignore | 3 ++- itd/client.py | 4 ++-- itd/exceptions.py | 6 +++++- itd/request.py | 4 +++- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 060af60..e4d0eb1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ venv/ __pycache__/ dist itd_sdk.egg-info -nowkie.gif \ No newline at end of file +nowkie.gif +g.gif \ No newline at end of file diff --git a/itd/client.py b/itd/client.py index 62f19bb..7105c0b 100644 --- a/itd/client.py +++ b/itd/client.py @@ -454,13 +454,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: Пост не найден diff --git a/itd/exceptions.py b/itd/exceptions.py index e8a8b51..376a30c 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -113,4 +113,8 @@ class NoContent(Exception): class AlreadyFollowing(Exception): def __str__(self) -> str: - return 'Already following user' \ No newline at end of file + return 'Already following user' + +class AccountBanned(Exception): + def __str__(self) -> str: + return 'Account has been deactivated' \ No newline at end of file diff --git a/itd/request.py b/itd/request.py index c634258..df888fc 100644 --- a/itd/request.py +++ b/itd/request.py @@ -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 s = Session() @@ -39,6 +39,8 @@ 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') == 'ACCOUNT_BANNED': + raise AccountBanned() except (JSONDecodeError, AttributeError): pass # todo From 62730b48e99d75b5a4dfef3663368d80ad8931be Mon Sep 17 00:00:00 2001 From: firedotguy Date: Thu, 12 Feb 2026 19:56:57 +0300 Subject: [PATCH 12/20] feat: add polls --- itd/client.py | 51 ++++++++++++++++++++++++++++++++++++++------- itd/exceptions.py | 22 ++++++++++++++----- itd/models/post.py | 51 +++++++++++++++++++++++++++++++++++++++++++-- itd/routes/posts.py | 22 ++++++++++++------- 4 files changed, 124 insertions(+), 22 deletions(-) diff --git a/itd/client.py b/itd/client.py index 7105c0b..502d4ba 100644 --- a/itd/client.py +++ b/itd/client.py @@ -13,7 +13,7 @@ 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 @@ -23,7 +23,7 @@ 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 from itd.models.clan import Clan from itd.models.hashtag import Hashtag from itd.models.user import User, UserProfileUpdate, UserPrivacy, UserFollower, UserWhoToFollow @@ -40,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 ) @@ -630,13 +630,14 @@ 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, 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. 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: Пользователь не найден @@ -645,7 +646,8 @@ class Client: Returns: NewPost: Новый пост """ - res = create_post(self.token, content, wall_recipient_id, attach_ids) + res = create_post(self.token, content, 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(): @@ -654,6 +656,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]: """Получить список постов diff --git a/itd/exceptions.py b/itd/exceptions.py index 376a30c..e545c7a 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -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): @@ -117,4 +117,16 @@ class AlreadyFollowing(Exception): class AccountBanned(Exception): def __str__(self) -> str: - return 'Account has been deactivated' \ No newline at end of file + 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)' \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index 429de17..97d766d 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,51 @@ from itd.models.file import PostAttach from itd.models.comment import Comment +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 _PostShort(TextObject): likes_count: int = Field(0, alias='likesCount') comments_count: int = Field(0, alias='commentsCount') @@ -39,8 +85,9 @@ class _Post(_PostShort): class Post(_Post, PostShort): - pass + poll: Poll | None = None class NewPost(_Post): author: UserNewPost + poll: NewPoll | None = None diff --git a/itd/routes/posts.py b/itd/routes/posts.py index 30eb802..1932e52 100644 --- a/itd/routes/posts.py +++ b/itd/routes/posts.py @@ -3,13 +3,16 @@ 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, wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = [], poll: NewPoll | None = None): + data: dict = {'content': content or ''} 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 +46,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]}) From b3b109613b06d0065cfc99bfa59ae642b7cb9e85 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Thu, 12 Feb 2026 23:40:59 +0300 Subject: [PATCH 13/20] feat: update privacy data --- itd/client.py | 22 ++++++++++++++++++---- itd/enums.py | 7 +++++++ itd/models/post.py | 1 + itd/models/user.py | 38 ++++++++++++++++++++++++++++++++++---- itd/routes/users.py | 4 ++++ 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/itd/client.py b/itd/client.py index 502d4ba..6b00fdf 100644 --- a/itd/client.py +++ b/itd/client.py @@ -8,7 +8,7 @@ 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 @@ -26,7 +26,7 @@ from itd.models.notification import Notification from itd.models.post import Post, NewPost, PollData, Poll 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 @@ -194,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. @@ -208,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: """Подписаться на пользователя @@ -615,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: """Получить количество непрочитанных уведомлений diff --git a/itd/enums.py b/itd/enums.py index a4255aa..8eb094e 100644 --- a/itd/enums.py +++ b/itd/enums.py @@ -33,3 +33,10 @@ class AttachType(Enum): class PostsTab(Enum): FOLLOWING = 'following' POPULAR = 'popular' + +class AccessType(Enum): + """Типы разрешений для видимости лайков и записей на стене""" + NOBODY = 'nobody' # никто + MUTUAL = 'mutual' # взаимные + FOLLOWERS = 'followers' # подписчики + EVERYONE = 'everyone' # все \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index 97d766d..b6ad7f0 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -74,6 +74,7 @@ class _Post(_PostShort): 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] = [] diff --git a/itd/models/user.py b/itd/models/user.py index 5a90f02..868c0e4 100644 --- a/itd/models/user.py +++ b/itd/models/user.py @@ -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 diff --git a/itd/routes/users.py b/itd/routes/users.py index ddbbfa4..4f81a96 100644 --- a/itd/routes/users.py +++ b/itd/routes/users.py @@ -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') From 6edc40308a3e45817b282125499c3f5ed1c7a55e Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 13 Feb 2026 22:38:28 +0300 Subject: [PATCH 14/20] feat: add profiel required exception; add spans to post --- itd/exceptions.py | 6 +++++- itd/models/post.py | 20 ++++++++++++++------ itd/request.py | 4 +++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/itd/exceptions.py b/itd/exceptions.py index e545c7a..22767f2 100644 --- a/itd/exceptions.py +++ b/itd/exceptions.py @@ -129,4 +129,8 @@ class NotMultipleChoice(Exception): class EmptyOptions(Exception): def __str__(self) -> str: - return 'Options cannot be empty (pre-validation)' \ No newline at end of file + return 'Options cannot be empty (pre-validation)' + +class ProfileRequired(Exception): + def __str__(self) -> str: + return 'No profile. Please create your profile first' \ No newline at end of file diff --git a/itd/models/post.py b/itd/models/post.py index b6ad7f0..5a8eaa1 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -54,22 +54,30 @@ class Poll(_Poll): return datetime.strptime(v, '%Y-%m-%dT%H:%M:%S.%f') -class _PostShort(TextObject): +class Span(BaseModel): + length: int + offset: int + type: SpanType + + +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') @@ -79,13 +87,13 @@ class _Post(_PostShort): 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): +class Post(_Post, _PostAuthor): poll: Poll | None = None diff --git a/itd/request.py b/itd/request.py index df888fc..391025c 100644 --- a/itd/request.py +++ b/itd/request.py @@ -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, AccountBanned +from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded, Unauthorized, AccountBanned, ProfileRequired s = Session() @@ -41,6 +41,8 @@ def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, raise Unauthorized() if res.json().get('error', {}).get('code') == 'ACCOUNT_BANNED': raise AccountBanned() + if res.json().get('error', {}).get('code') == 'PROFILE_REQUIRED': + raise ProfileRequired() except (JSONDecodeError, AttributeError): pass # todo From 7795fb6d7ef190c45fcf60777cb3183dfdf06dd2 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Fri, 13 Feb 2026 23:27:47 +0300 Subject: [PATCH 15/20] docs: main account ban --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d0a4c2..4c41cbc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # itd-sdk Клиент ITD для python +> [!WARNING] +> Мой основной аккаунт itd_sdk был забанен. Новый акк - itdsdk. #димонверниаккаунты ## Установка @@ -123,4 +125,4 @@ fetch(c.token, 'метод', 'эндпоинт', {'данные': 'данные' Идея (и часть эндпоинтов): 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) (в тг) From 1c452c147c2c3cb6a5cd21a8be57c2e61ef131db Mon Sep 17 00:00:00 2001 From: firedotguy <158167689+firedotguy@users.noreply.github.com> Date: Sat, 14 Feb 2026 03:00:30 +0600 Subject: [PATCH 16/20] =?UTF-8?q?docs:=20=D0=BD=D0=BE=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=83=D0=BC=D1=80=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4c41cbc..38aa44d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # itd-sdk Клиент ITD для python -> [!WARNING] -> Мой основной аккаунт itd_sdk был забанен. Новый акк - itdsdk. #димонверниаккаунты +> [!CAUTION] +> ~~Мой основной аккаунт itd_sdk был забанен. Новый акк - itdsdk. #димонверниаккаунты~~ +> Провет больше не будет обнолвяться! я изолировался от итд и от новки в целом. PR буду мержить ## Установка From 839ad8aaa869aa8cc3e46895b3897b32f839c91d Mon Sep 17 00:00:00 2001 From: firedotguy <158167689+firedotguy@users.noreply.github.com> Date: Sun, 1 Mar 2026 02:09:28 +0600 Subject: [PATCH 17/20] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38aa44d..703a1d6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Клиент ITD для python > [!CAUTION] > ~~Мой основной аккаунт itd_sdk был забанен. Новый акк - itdsdk. #димонверниаккаунты~~ -> Провет больше не будет обнолвяться! я изолировался от итд и от новки в целом. PR буду мержить +> ~~Проект больше не будет обнолвяться! яPR буду мержить~~ +> ладно, буду мейнтйнить потихоньку... ## Установка From cd27baa8d65b36cfb1d030bc5a578ac6efb45445 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sat, 28 Feb 2026 23:27:41 +0300 Subject: [PATCH 18/20] feat: add spans --- itd/enums.py | 11 ++++- itd/models/post.py | 1 + itd/request.py | 2 +- itd/utils.py | 114 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 itd/utils.py diff --git a/itd/enums.py b/itd/enums.py index 8eb094e..201373f 100644 --- a/itd/enums.py +++ b/itd/enums.py @@ -39,4 +39,13 @@ class AccessType(Enum): NOBODY = 'nobody' # никто MUTUAL = 'mutual' # взаимные FOLLOWERS = 'followers' # подписчики - EVERYONE = 'everyone' # все \ No newline at end of file + EVERYONE = 'everyone' # все + +class SpanType(Enum): + MONOSPACE = 'monospace' # моноширный (код) + STRIKE = 'strike' # зачеркнутый + BOLD = 'bold' # жирный + ITALIC = 'italic' # курсив + SPOILER = 'spoiler' # спойлер + UNDERLINE = 'underline' # подчеркнутый + HASHTAG = 'hashtag' # хэштэг ? (появляется только при получении постов, при создании нету) diff --git a/itd/models/post.py b/itd/models/post.py index 5a8eaa1..affc289 100644 --- a/itd/models/post.py +++ b/itd/models/post.py @@ -7,6 +7,7 @@ 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 NewPollOption(BaseModel): diff --git a/itd/request.py b/itd/request.py index 391025c..a5acaa0 100644 --- a/itd/request.py +++ b/itd/request.py @@ -39,7 +39,7 @@ 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') == 'ACCOUNT_BANNED': + 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() diff --git a/itd/utils.py b/itd/utils.py new file mode 100644 index 0000000..5074ea6 --- /dev/null +++ b/itd/utils.py @@ -0,0 +1,114 @@ +# новая версия от чат гпт. у меня самого не получилось сделать +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]] = [] + clean_chars: list[str] = [] + i = 0 + + while i < len(text): + closed = False + for idx, tag in enumerate(tags): + if text.startswith(tag.close, i) and stack and stack[-1][0] == idx: + _, span_type, offset, _ = stack.pop() + spans.append(Span(length=len(clean_chars) - offset, offset=offset, type=span_type)) + i += len(tag.close) + closed = True + break + if closed: + continue + + opened = False + for idx, tag in enumerate(tags): + if text.startswith(tag.open, i): + stack.append((idx, tag.type, len(clean_chars), i)) + i += len(tag.open) + opened = True + break + if opened: + 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('', '', SpanType.BOLD), + Tag('', '', SpanType.ITALIC), + Tag('', '', SpanType.STRIKE), + Tag('', '', SpanType.UNDERLINE), + Tag('', '', SpanType.MONOSPACE), + Tag('', '', SpanType.SPOILER), + ], + ) + + +# версия от человека (не работает с вложенными тэгами) +# 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('', '', SpanType.BOLD), +# Tag('', '', SpanType.ITALIC), +# Tag('', '', SpanType.STRIKE), +# Tag('', '', SpanType.UNDERLINE), +# Tag('', '', SpanType.MONOSPACE), +# Tag('', '', 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 From 86a378b61395d0892a6a659621bf34866313f425 Mon Sep 17 00:00:00 2001 From: firedotguy Date: Sat, 28 Feb 2026 23:48:26 +0300 Subject: [PATCH 19/20] feat: add spans in client --- README.md | 11 +++++++++++ itd/client.py | 7 ++++--- itd/enums.py | 2 ++ itd/routes/posts.py | 4 +++- itd/utils.py | 4 +++- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 703a1d6..a7bcfda 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,17 @@ c.create_post('тест1') # создание постов # итд ``` +### Стилизация постов ("спаны") +С обновления 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('значит, я это щас отправил со своего клиента, воот. И еще тут спаны написаны через html, по типу < i > 11'))) +``` +