Merge branch 'main' into main
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ __pycache__/
|
|||||||
dist
|
dist
|
||||||
itd_sdk.egg-info
|
itd_sdk.egg-info
|
||||||
nowkie.gif
|
nowkie.gif
|
||||||
|
g.gif
|
||||||
61
README.md
61
README.md
@@ -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
|
||||||
> 
|
>  -->
|
||||||
|
|
||||||
|
### Получение 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` - любое число или буква). Они необязательные и их наличие не влияет на результат
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
### Скрипт на обновление имени
|
### Скрипт на обновление имени
|
||||||
@@ -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) (в тг)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
7
examples/kilyabin/README.md
Normal file
7
examples/kilyabin/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Коллекция скриптов от [@kilyabin](https://github.com/kilyabin)
|
||||||
|
|
||||||
|
Пока всего два скрипта, однако будет дополняться
|
||||||
|
|
||||||
|
Работают через аргументы командной строки (например, `--file` или `--text`)
|
||||||
|
|
||||||
|
Есть помощь при аргументе `-h`, потому - разберетесь
|
||||||
56
examples/kilyabin/itd-change-banner.py
Normal file
56
examples/kilyabin/itd-change-banner.py
Normal 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()
|
||||||
78
examples/kilyabin/itd-create-post.py
Normal file
78
examples/kilyabin/itd-create-post.py
Normal 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()
|
||||||
@@ -5,7 +5,7 @@ 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'
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ 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 для инструкций')
|
||||||
@@ -18,11 +19,10 @@ def main():
|
|||||||
|
|
||||||
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...')
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ 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'
|
||||||
@@ -18,7 +17,6 @@ def main():
|
|||||||
|
|
||||||
client = ITDClient(cookies=cookies)
|
client = ITDClient(cookies=cookies)
|
||||||
|
|
||||||
# Функция для прослушивания в отдельном потоке
|
|
||||||
def listen():
|
def listen():
|
||||||
print('! Начинаем прослушивание...')
|
print('! Начинаем прослушивание...')
|
||||||
try:
|
try:
|
||||||
@@ -30,7 +28,6 @@ def main():
|
|||||||
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()
|
||||||
|
|
||||||
|
|||||||
107
itd/client.py
107
itd/client.py
@@ -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,6 +1153,7 @@ 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 поток уведомлений
|
||||||
@@ -1130,14 +1196,14 @@ class Client:
|
|||||||
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:
|
||||||
@@ -1155,7 +1221,7 @@ 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):
|
||||||
@@ -1183,4 +1249,5 @@ class Client:
|
|||||||
thread.join()
|
thread.join()
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
print('stop event')
|
||||||
self._stream_active = False
|
self._stream_active = False
|
||||||
18
itd/enums.py
18
itd/enums.py
@@ -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' # цитата
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -114,3 +114,23 @@ 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'
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
195
itd/utils.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
4
setup.py
4
setup.py
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user