Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bc20a1192 | ||
|
|
994a38e945 | ||
|
|
86a378b613 | ||
|
|
ec58bae1e8 | ||
|
|
cd27baa8d6 | ||
|
|
839ad8aaa8 | ||
|
|
1c452c147c | ||
|
|
7795fb6d7e | ||
|
|
6edc40308a | ||
|
|
b3b109613b | ||
|
|
62730b48e9 | ||
|
|
c1042d32ae | ||
|
|
7cc343dab5 | ||
|
|
aad83b55d5 | ||
|
|
8e8b0b3bb9 | ||
|
|
51518ce0d7 | ||
|
|
d49fb2d4cb | ||
|
|
337a1eb17b | ||
|
|
3ff5b90380 | ||
|
|
a3a3c012ff | ||
|
|
f2e18e08c0 | ||
|
|
13365fc23a | ||
|
|
a47b4d01b5 | ||
|
|
0f3ada9148 | ||
|
|
8935233276 | ||
|
|
ff5e410307 | ||
|
|
1a4f9f6c5a | ||
|
|
de48c30c78 | ||
|
|
9a4c47bd8e | ||
|
|
c2413277d6 | ||
|
|
5ebcdb1ad5 | ||
|
|
6236c5981b | ||
|
|
27bd528883 | ||
|
|
63dfedc917 | ||
|
|
006bda53c1 | ||
|
|
201f720843 | ||
|
|
8aef43e11d | ||
|
|
506e6a5d09 | ||
|
|
f33ed4f76a | ||
|
|
2d27507338 | ||
|
|
b2bd982c1b | ||
|
|
7d5275c880 | ||
|
|
a2d019e1d6 | ||
|
|
844671ab4e | ||
|
|
38805156bf | ||
|
|
ae8d4c04d5 | ||
|
|
65cd617a1f | ||
|
|
55630bc23f | ||
|
|
60ae5feb21 | ||
|
|
dd7b8c077e | ||
|
|
2f026a32d7 | ||
|
|
dc72c6fb4b | ||
|
|
b82d4fc30b | ||
|
|
295501462a | ||
|
|
eb83c724cc | ||
|
|
a547bbfdfb | ||
|
|
6ac12c6f79 | ||
|
|
0c212e41cb | ||
|
|
80a5a33443 | ||
|
|
7ce699e42a | ||
|
|
3ad003a0b1 | ||
|
|
2ccd26fc9b | ||
|
|
af0b2a1acc | ||
|
|
c9a5dcad10 | ||
|
|
1db59149f4 | ||
|
|
ba78457de5 | ||
|
|
2a9f7da9a9 | ||
|
|
8d03171925 | ||
|
|
a388426d8d | ||
|
|
c7e3812ee8 | ||
|
|
1a606da55f | ||
|
|
aa20199ebe | ||
|
|
49427a5535 | ||
|
|
d27db1d905 | ||
|
|
70bb1e75d6 | ||
|
|
97c6812819 | ||
|
|
f1d9a0b2f0 | ||
|
|
10751f9ddb |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,8 @@
|
|||||||
test.py
|
test.py
|
||||||
|
like.py
|
||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
dist
|
dist
|
||||||
itd_sdk.egg-info
|
itd_sdk.egg-info
|
||||||
|
nowkie.gif
|
||||||
|
g.gif
|
||||||
112
README.md
112
README.md
@@ -1,11 +1,15 @@
|
|||||||
# pyITDclient
|
# itd-sdk
|
||||||
Клиент ITD для python
|
Клиент ITD для python
|
||||||
|
> [!CAUTION]
|
||||||
|
> ~~Мой основной аккаунт itd_sdk был забанен. Новый акк - itdsdk. #димонверниаккаунты~~
|
||||||
|
> ~~Проект больше не будет обнолвяться! яPR буду мержить~~
|
||||||
|
> ладно, буду мейнтйнить потихоньку...
|
||||||
|
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install pyITDclient
|
pip install itd-sdk
|
||||||
```
|
```
|
||||||
|
|
||||||
## Пример
|
## Пример
|
||||||
@@ -17,6 +21,55 @@ c = ITDClient('TOKEN', 'refresh_token=...; __ddg1_=...; __ddgid_=...; is_auth=1;
|
|||||||
# можно указать только токен, тогда после просрочки перестанет работать, либо только куки чтобы токен сразу подтянулся, либо оба сразу
|
# можно указать только токен, тогда после просрочки перестанет работать, либо только куки чтобы токен сразу подтянулся, либо оба сразу
|
||||||
|
|
||||||
print(c.get_me())
|
print(c.get_me())
|
||||||
|
```
|
||||||
|
<!--
|
||||||
|
> [!NOTE]
|
||||||
|
> Берите куки из запроса /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` - любое число или буква). Они необязательные и их наличие не влияет на результат
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
### Скрипт на обновление имени
|
||||||
|
Этот код сейчас работает на @itd_sdk (обновляется имя и пост)
|
||||||
|
```python
|
||||||
|
from itd import ITDClient
|
||||||
|
from time import sleep
|
||||||
|
from random import randint
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
c = ITDClient(None, '...')
|
||||||
|
|
||||||
|
while True:
|
||||||
|
c.update_profile(display_name=f'PYTHON ITD SDK | Рандом: {randint(1, 100)} | {datetime.now().strftime("%m.%d %H:%M:%S")}')
|
||||||
|
# редактирование поста
|
||||||
|
# c.edit_post('82ea8a4f-a49e-485e-b0dc-94d7da9df990', f'рил ща {datetime.now(timezone.utc).isoformat(" ")} по UTC (обновляется каждую секунду)')
|
||||||
|
sleep(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Скрипт на смену баннера
|
||||||
|
```python
|
||||||
|
from itd import ITDClient
|
||||||
|
|
||||||
|
c = ITDClient(None, 'Ваши cookies')
|
||||||
|
|
||||||
|
c.update_banner('имя-файла.png')
|
||||||
|
print('баннер обновлен')
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Встроенные запросы
|
### Встроенные запросы
|
||||||
@@ -29,6 +82,52 @@ 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('значит, я это щас отправил со своего клиента, <b>воот</b>. И еще тут спаны написаны через html, по типу < i > <i>11</i>')))
|
||||||
|
```
|
||||||
|
Поддерживаемые теги:
|
||||||
|
- `<b>`: жирный
|
||||||
|
- `<i>`: курсивный
|
||||||
|
- `<s>`: зачеркнутый
|
||||||
|
- `<u>`: подчеркнутый
|
||||||
|
- `<code>`: код
|
||||||
|
- `<spoiler>`: спойлер
|
||||||
|
- `<a href="https://google.com">`: ссылка
|
||||||
|
- `<q>`: цитата
|
||||||
|
|
||||||
|
<!-- ### SSE - прослушивание уведомлений в реальном времени
|
||||||
|
|
||||||
|
```python
|
||||||
|
from itd import ITDClient, StreamConnect, StreamNotification
|
||||||
|
|
||||||
|
# Используйте 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
|
```python
|
||||||
@@ -39,12 +138,13 @@ fetch(c.token, 'метод', 'эндпоинт', {'данные': 'данные'
|
|||||||
Из методов поддерживается `get`, `post`, `put` итд, которые есть в `requests`
|
Из методов поддерживается `get`, `post`, `put` итд, которые есть в `requests`
|
||||||
К названию эндпоинта добавляется домен итд и `api`, то есть в этом примере отпарвится `https://xn--d1ah4a.com/api/эндпоинт`.
|
К названию эндпоинта добавляется домен итд и `api`, то есть в этом примере отпарвится `https://xn--d1ah4a.com/api/эндпоинт`.
|
||||||
|
|
||||||
> ![INFO]
|
> [!NOTE]
|
||||||
> `xn--d1ah4a.com` - punycode от "итд.com"
|
> `xn--d1ah4a.com` - punycode от "итд.com"
|
||||||
|
|
||||||
## прочее
|
|
||||||
Лицезия: [MIT](./LICENSE)
|
## Прочее
|
||||||
|
Лицезия: [MIT](./LICENSE)
|
||||||
Идея (и часть эндпоинтов): https://github.com/FriceKa/ITD-SDK-js
|
Идея (и часть эндпоинтов): https://github.com/FriceKa/ITD-SDK-js
|
||||||
- По сути этот проект является реворком, просто на другом языке
|
- По сути этот проект является реворком, просто на другом языке
|
||||||
|
|
||||||
Автор: [SizedBox](https://xn--d1ah4a.com/SizedBox) (в итд) [@desicars](https://t.me/desicars) (в тг)
|
Автор: ~~[itd_sdk](https://xn--d1ah4a.com/itd_sdk) забанили~~ [itdsdk](https://xn--d1ah4a.com/itdsdk) (в итд) [@desicars](https://t.me/desicars) (в тг)
|
||||||
|
|||||||
BIN
cookie-screen.png
Normal file
BIN
cookie-screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 293 KiB |
17
examples/README.md
Normal file
17
examples/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Примеры использования ITD SDK
|
||||||
|
|
||||||
|
Эта папка содержит примеры использования ITD SDK для различных сценариев.
|
||||||
|
|
||||||
|
## Дополнительная информация
|
||||||
|
|
||||||
|
- [Основной README](../README.md) - Документация по всему SDK
|
||||||
|
- Каждая папка с примерами содержит свой README с подробностями
|
||||||
|
|
||||||
|
## Помощь
|
||||||
|
|
||||||
|
Если примеры не работают:
|
||||||
|
|
||||||
|
1. Проверьте, что cookies актуальные (не истекли)
|
||||||
|
2. Убедитесь, что установлены все зависимости
|
||||||
|
3. Проверьте формат cookies (должен содержать `refresh_token=`)
|
||||||
|
4. Используйте Python 3.13+ (для поддержки `deprecated`)
|
||||||
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()
|
||||||
75
examples/stream/README.md
Normal file
75
examples/stream/README.md
Normal file
@@ -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`
|
||||||
37
examples/stream/basic_stream.py
Normal file
37
examples/stream/basic_stream.py
Normal file
@@ -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
|
||||||
|
|
||||||
|
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()
|
||||||
54
examples/stream/filter_notifications.py
Normal file
54
examples/stream/filter_notifications.py
Normal file
@@ -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
|
||||||
|
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()
|
||||||
60
examples/stream/notification_logger.py
Normal file
60
examples/stream/notification_logger.py
Normal file
@@ -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
|
||||||
|
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()
|
||||||
44
examples/stream/stop_stream.py
Normal file
44
examples/stream/stop_stream.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
Пример остановки SSE потока из кода
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from itd import ITDClient, StreamConnect
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -1 +1,4 @@
|
|||||||
from itd.client import Client as ITDClient
|
from itd.client import Client as ITDClient
|
||||||
|
from itd.models.event import StreamConnect, StreamNotification
|
||||||
|
|
||||||
|
__all__ = ['ITDClient', 'StreamConnect', 'StreamNotification']
|
||||||
1266
itd/client.py
1266
itd/client.py
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
from itd.request import fetch
|
|
||||||
|
|
||||||
def add_comment(token: str, post_id: str, content: str, reply_comment_id: str | None = None):
|
|
||||||
data = {'content': content}
|
|
||||||
if reply_comment_id:
|
|
||||||
data['replyTo'] = str(reply_comment_id)
|
|
||||||
return fetch(token, 'post', f'posts/{post_id}/comments', data)
|
|
||||||
|
|
||||||
def get_comments(token: str, post_id: str, limit: int = 20, cursor: int = 0, sort: str = 'popular'):
|
|
||||||
return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor})
|
|
||||||
|
|
||||||
def like_comment(token: str, comment_id: str):
|
|
||||||
return fetch(token, 'post', f'comments/{comment_id}/like')
|
|
||||||
|
|
||||||
def unlike_comment(token: str, comment_id: str):
|
|
||||||
return fetch(token, 'delete', f'comments/{comment_id}/like')
|
|
||||||
|
|
||||||
def delete_comment(token: str, comment_id: str):
|
|
||||||
return fetch(token, 'delete', f'comments/{comment_id}')
|
|
||||||
53
itd/enums.py
Normal file
53
itd/enums.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class NotificationType(Enum):
|
||||||
|
WALL_POST = 'wall_post'
|
||||||
|
REPLY = 'reply'
|
||||||
|
REPOST = 'repost'
|
||||||
|
COMMENT = 'comment'
|
||||||
|
FOLLOW = 'follow'
|
||||||
|
LIKE = 'like'
|
||||||
|
|
||||||
|
class NotificationTargetType(Enum):
|
||||||
|
POST = 'post'
|
||||||
|
|
||||||
|
class ReportTargetType(Enum):
|
||||||
|
POST = 'post'
|
||||||
|
USER = 'user'
|
||||||
|
COMMENT = 'comment'
|
||||||
|
|
||||||
|
class ReportTargetReason(Enum):
|
||||||
|
SPAM = 'spam' # спам
|
||||||
|
VIOLENCE = 'violence' # насилие
|
||||||
|
HATE = 'hate' # ненависть
|
||||||
|
ADULT = 'adult' # 18+
|
||||||
|
FRAUD = 'fraud' # обман\мошенничество
|
||||||
|
OTHER = 'other' # другое
|
||||||
|
|
||||||
|
class AttachType(Enum):
|
||||||
|
AUDIO = 'audio'
|
||||||
|
IMAGE = 'image'
|
||||||
|
VIDEO = 'video'
|
||||||
|
FILE = 'file'
|
||||||
|
|
||||||
|
class PostsTab(Enum):
|
||||||
|
FOLLOWING = 'following'
|
||||||
|
POPULAR = 'popular'
|
||||||
|
|
||||||
|
class AccessType(Enum):
|
||||||
|
"""Типы разрешений для видимости лайков и записей на стене"""
|
||||||
|
NOBODY = 'nobody' # никто
|
||||||
|
MUTUAL = 'mutual' # взаимные
|
||||||
|
FOLLOWERS = 'followers' # подписчики
|
||||||
|
EVERYONE = 'everyone' # все
|
||||||
|
|
||||||
|
class SpanType(Enum):
|
||||||
|
MONOSPACE = 'monospace' # моноширный (код)
|
||||||
|
STRIKE = 'strike' # зачеркнутый
|
||||||
|
BOLD = 'bold' # жирный
|
||||||
|
ITALIC = 'italic' # курсив
|
||||||
|
SPOILER = 'spoiler' # спойлер
|
||||||
|
UNDERLINE = 'underline' # подчеркнутый
|
||||||
|
HASHTAG = 'hashtag' # хэштэг ? (появляется только при получении постов, при создании нету)
|
||||||
|
LINK = 'link' # ссылка
|
||||||
|
QUOTE = 'quote' # цитата
|
||||||
136
itd/exceptions.py
Normal file
136
itd/exceptions.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
class NoCookie(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'No cookie for refresh-token required action'
|
||||||
|
|
||||||
|
class NoAuthData(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'No auth data. Provide token or cookies'
|
||||||
|
|
||||||
|
class InvalidCookie(Exception):
|
||||||
|
def __init__(self, code: str):
|
||||||
|
self.code = code
|
||||||
|
def __str__(self):
|
||||||
|
if self.code == 'SESSION_NOT_FOUND':
|
||||||
|
return 'Invalid cookie data: Session not found (incorrect refresh token)'
|
||||||
|
elif self.code == 'REFRESH_TOKEN_MISSING':
|
||||||
|
return 'Invalid cookie data: No refresh token'
|
||||||
|
elif self.code == 'SESSION_EXPIRED':
|
||||||
|
return 'Invalid cookie data: Session expired'
|
||||||
|
# SESSION_REVOKED
|
||||||
|
return 'Invalid cookie data: Session revoked (logged out)'
|
||||||
|
|
||||||
|
class InvalidToken(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Invalid access token'
|
||||||
|
|
||||||
|
class SamePassword(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Old and new password must not equals'
|
||||||
|
|
||||||
|
class InvalidOldPassword(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Old password is incorrect'
|
||||||
|
|
||||||
|
class NotFound(Exception):
|
||||||
|
def __init__(self, obj: str):
|
||||||
|
self.obj = obj
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.obj} not found'
|
||||||
|
|
||||||
|
class NotFoundOrForbidden(Exception):
|
||||||
|
def __init__(self, obj: str):
|
||||||
|
self.obj = obj
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.obj} not found or access denied'
|
||||||
|
|
||||||
|
|
||||||
|
class UserBanned(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'User banned'
|
||||||
|
|
||||||
|
class ValidationError(Exception):
|
||||||
|
# def __init__(self, name: str, value: str):
|
||||||
|
# self.name = name
|
||||||
|
# self.value = value
|
||||||
|
def __str__(self):
|
||||||
|
return 'Failed validation'# on {self.name}: "{self.value}"'
|
||||||
|
|
||||||
|
class PendingRequestExists(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Pending verifiaction request already exists'
|
||||||
|
|
||||||
|
class RateLimitExceeded(Exception):
|
||||||
|
def __init__(self, retry_after: int):
|
||||||
|
self.retry_after = retry_after
|
||||||
|
def __str__(self):
|
||||||
|
return f'Rate limit exceeded - too much requests. Retry after {self.retry_after} seconds'
|
||||||
|
|
||||||
|
class Forbidden(Exception):
|
||||||
|
def __init__(self, action: str):
|
||||||
|
self.action = action
|
||||||
|
def __str__(self):
|
||||||
|
return f'Forbidden to {self.action}'
|
||||||
|
|
||||||
|
class UsernameTaken(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Username is already taken'
|
||||||
|
|
||||||
|
class CantFollowYourself(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Cannot follow yourself'
|
||||||
|
|
||||||
|
class Unauthorized(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Auth required - refresh token'
|
||||||
|
|
||||||
|
class CantRepostYourPost(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Cannot repost your own post'
|
||||||
|
|
||||||
|
class AlreadyReposted(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Post already reposted'
|
||||||
|
|
||||||
|
class AlreadyReported(Exception):
|
||||||
|
def __init__(self, obj: str) -> None:
|
||||||
|
self.obj = obj
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.obj} already reported'
|
||||||
|
|
||||||
|
class TooLarge(Exception):
|
||||||
|
def __str__(self):
|
||||||
|
return 'Search query too large'
|
||||||
|
|
||||||
|
class PinNotOwned(Exception):
|
||||||
|
def __init__(self, pin: str) -> None:
|
||||||
|
self.pin = pin
|
||||||
|
def __str__(self):
|
||||||
|
return f'You do not own "{self.pin}" pin'
|
||||||
|
|
||||||
|
class NoContent(Exception):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return 'Content or attachments required'
|
||||||
|
|
||||||
|
class AlreadyFollowing(Exception):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
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'
|
||||||
3
itd/models/__init__.py
Normal file
3
itd/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from itd.models.event import StreamConnect, StreamNotification
|
||||||
|
|
||||||
|
__all__ = ['StreamConnect', 'StreamNotification']
|
||||||
21
itd/models/_text.py
Normal file
21
itd/models/_text.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class TextObject(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
content: str
|
||||||
|
|
||||||
|
created_at: datetime = Field(alias='createdAt')
|
||||||
|
|
||||||
|
model_config = {'populate_by_name': True}
|
||||||
|
|
||||||
|
@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.%fZ')
|
||||||
6
itd/models/clan.py
Normal file
6
itd/models/clan.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Clan(BaseModel):
|
||||||
|
avatar: str
|
||||||
|
member_count: int = Field(0, alias='memberCount')
|
||||||
17
itd/models/comment.py
Normal file
17
itd/models/comment.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from itd.models._text import TextObject
|
||||||
|
from itd.models.user import UserPost
|
||||||
|
from itd.models.file import Attach
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(TextObject):
|
||||||
|
author: UserPost
|
||||||
|
|
||||||
|
likes_count: int = Field(0, alias='likesCount')
|
||||||
|
replies_count: int = Field(0, alias='repliesCount')
|
||||||
|
is_liked: bool = Field(False, alias='isLiked')
|
||||||
|
|
||||||
|
attachments: list[Attach] = []
|
||||||
|
replies: list['Comment'] = []
|
||||||
|
reply_to: UserPost | None = None # author of replied comment, if this comment is reply
|
||||||
17
itd/models/event.py
Normal file
17
itd/models/event.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from itd.models.notification import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class StreamConnect(BaseModel):
|
||||||
|
"""Событие подключения к SSE потоку"""
|
||||||
|
user_id: UUID = Field(alias='userId')
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
|
||||||
|
class StreamNotification(Notification):
|
||||||
|
"""Уведомление из SSE потока"""
|
||||||
|
user_id: UUID = Field(alias='userId')
|
||||||
|
sound: bool = True
|
||||||
31
itd/models/file.py
Normal file
31
itd/models/file.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from itd.enums import AttachType
|
||||||
|
|
||||||
|
class File(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
url: str
|
||||||
|
filename: str
|
||||||
|
mime_type: str = Field(alias='mimeType')
|
||||||
|
size: int
|
||||||
|
created_at: datetime | None = Field(None, alias='createdAt')
|
||||||
|
|
||||||
|
|
||||||
|
class PostAttach(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
type: AttachType = AttachType.IMAGE
|
||||||
|
url: str
|
||||||
|
thumbnail_url: str | None = Field(None, alias='thumbnailUrl')
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Attach(PostAttach):
|
||||||
|
filename: str
|
||||||
|
mime_type: str = Field(alias='mimeType')
|
||||||
|
size: int
|
||||||
|
duration: int | None = None
|
||||||
|
order: int = 0
|
||||||
8
itd/models/hashtag.py
Normal file
8
itd/models/hashtag.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class Hashtag(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
posts_count: int = Field(0, alias='postsCount')
|
||||||
22
itd/models/notification.py
Normal file
22
itd/models/notification.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from itd.enums import NotificationType, NotificationTargetType
|
||||||
|
from itd.models.user import UserNotification
|
||||||
|
|
||||||
|
class Notification(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
type: NotificationType
|
||||||
|
|
||||||
|
target_type: NotificationTargetType | None = Field(None, alias='targetType') # none - follows, other - NotificationTragetType.POST
|
||||||
|
target_id: UUID | None = Field(None, alias='targetId') # none - follows
|
||||||
|
|
||||||
|
preview: str | None = None # follow - none, comment/reply - content, repost - original post content, like - post content, wall_post - wall post content
|
||||||
|
|
||||||
|
read: bool = False
|
||||||
|
read_at: datetime | None = Field(None, alias='readAt')
|
||||||
|
created_at: datetime = Field(alias='createdAt')
|
||||||
|
|
||||||
|
actor: UserNotification
|
||||||
23
itd/models/pagination.py
Normal file
23
itd/models/pagination.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class Pagination(BaseModel):
|
||||||
|
page: int | None = 1
|
||||||
|
limit: int = 20
|
||||||
|
total: int | None = None
|
||||||
|
has_more: bool = Field(True, alias='hasMore')
|
||||||
|
next_cursor: UUID | None = Field(None, alias='nextCursor')
|
||||||
|
|
||||||
|
|
||||||
|
class PostsPagintaion(BaseModel):
|
||||||
|
limit: int = 20
|
||||||
|
next_cursor: int | None = Field(1, alias='nextCursor')
|
||||||
|
has_more: bool = Field(True, alias='hasMore')
|
||||||
|
|
||||||
|
|
||||||
|
class LikedPostsPagintaion(BaseModel):
|
||||||
|
limit: int = 20
|
||||||
|
next_cursor: datetime | None = Field(None, alias='nextCursor')
|
||||||
|
has_more: bool = Field(True, alias='hasMore')
|
||||||
12
itd/models/pin.py
Normal file
12
itd/models/pin.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class ShortPin(BaseModel):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class Pin(ShortPin):
|
||||||
|
granted_at: datetime = Field(alias='grantedAt')
|
||||||
104
itd/models/post.py
Normal file
104
itd/models/post.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import Field, BaseModel, field_validator
|
||||||
|
|
||||||
|
from itd.models.user import UserPost, UserNewPost
|
||||||
|
from itd.models._text import TextObject
|
||||||
|
from itd.models.file import PostAttach
|
||||||
|
from itd.models.comment import Comment
|
||||||
|
from itd.enums import SpanType
|
||||||
|
|
||||||
|
|
||||||
|
class NewPollOption(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class PollOption(NewPollOption):
|
||||||
|
id: UUID
|
||||||
|
position: int = 0
|
||||||
|
votes: int = Field(0, alias='votesCount')
|
||||||
|
|
||||||
|
|
||||||
|
class _Poll(BaseModel):
|
||||||
|
multiple: bool = Field(False, alias='multipleChoice')
|
||||||
|
question: str
|
||||||
|
|
||||||
|
|
||||||
|
class NewPoll(_Poll):
|
||||||
|
options: list[NewPollOption]
|
||||||
|
model_config = {'serialize_by_alias': True}
|
||||||
|
|
||||||
|
|
||||||
|
class PollData:
|
||||||
|
def __init__(self, question: str, options: list[str], multiple: bool = False):
|
||||||
|
self.poll = NewPoll(question=question, options=[NewPollOption(text=option) for option in options], multipleChoice=multiple)
|
||||||
|
|
||||||
|
|
||||||
|
class Poll(_Poll):
|
||||||
|
id: UUID
|
||||||
|
post_id: UUID = Field(alias='postId')
|
||||||
|
|
||||||
|
options: list[PollOption]
|
||||||
|
votes: int = Field(0, alias='totalVotes')
|
||||||
|
is_voted: bool = Field(False, alias='hasVoted')
|
||||||
|
voted_option_ids: list[UUID] = Field([], alias='votedOptionIds')
|
||||||
|
|
||||||
|
created_at: datetime = Field(alias='createdAt')
|
||||||
|
|
||||||
|
@field_validator('created_at', mode='plain')
|
||||||
|
@classmethod
|
||||||
|
def validate_created_at(cls, v: str):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(v + '00', '%Y-%m-%d %H:%M:%S.%f%z')
|
||||||
|
except ValueError:
|
||||||
|
return datetime.strptime(v, '%Y-%m-%dT%H:%M:%S.%f')
|
||||||
|
|
||||||
|
|
||||||
|
class Span(BaseModel):
|
||||||
|
length: int
|
||||||
|
offset: int
|
||||||
|
type: SpanType
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class _PostCounts(TextObject):
|
||||||
|
likes_count: int = Field(0, alias='likesCount')
|
||||||
|
comments_count: int = Field(0, alias='commentsCount')
|
||||||
|
reposts_count: int = Field(0, alias='repostsCount')
|
||||||
|
views_count: int = Field(0, alias='viewsCount')
|
||||||
|
|
||||||
|
spans: list[Span] = []
|
||||||
|
|
||||||
|
|
||||||
|
class _PostAuthor(_PostCounts):
|
||||||
|
author: UserPost
|
||||||
|
|
||||||
|
|
||||||
|
class OriginalPost(_PostAuthor):
|
||||||
|
is_deleted: bool = Field(False, alias='isDeleted')
|
||||||
|
|
||||||
|
|
||||||
|
class _Post(_PostCounts):
|
||||||
|
is_liked: bool = Field(False, alias='isLiked')
|
||||||
|
is_reposted: bool = Field(False, alias='isReposted')
|
||||||
|
is_viewed: bool = Field(False, alias='isViewed')
|
||||||
|
is_owner: bool = Field(False, alias='isOwner')
|
||||||
|
is_pinned: bool = Field(False, alias='isPinned') # only for user wall
|
||||||
|
|
||||||
|
attachments: list[PostAttach] = []
|
||||||
|
comments: list[Comment] = []
|
||||||
|
|
||||||
|
original_post: OriginalPost | None = None # for reposts
|
||||||
|
|
||||||
|
wall_recipient_id: UUID | None = Field(None, alias='wallRecipientId')
|
||||||
|
wall_recipient: UserPost | None = Field(None, alias='wallRecipient')
|
||||||
|
|
||||||
|
|
||||||
|
class Post(_Post, _PostAuthor):
|
||||||
|
poll: Poll | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NewPost(_Post):
|
||||||
|
author: UserNewPost
|
||||||
|
poll: NewPoll | None = None
|
||||||
19
itd/models/report.py
Normal file
19
itd/models/report.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from itd.enums import ReportTargetType, ReportTargetReason
|
||||||
|
|
||||||
|
|
||||||
|
class NewReport(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
created_at: datetime = Field(alias='createdAt')
|
||||||
|
|
||||||
|
|
||||||
|
class Report(NewReport):
|
||||||
|
reason: ReportTargetReason
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
target_type: ReportTargetType = Field(alias='targetType')
|
||||||
|
target_id: UUID
|
||||||
94
itd/models/user.py
Normal file
94
itd/models/user.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from itd.models.pin import ShortPin
|
||||||
|
from itd.enums import AccessType
|
||||||
|
|
||||||
|
|
||||||
|
class _UserPrivacy(BaseModel):
|
||||||
|
private: bool | None = Field(None, alias='isPrivate') # none for not me
|
||||||
|
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 = {'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):
|
||||||
|
id: UUID
|
||||||
|
username: str | None = None
|
||||||
|
display_name: str = Field(alias='displayName')
|
||||||
|
bio: str | None = None
|
||||||
|
|
||||||
|
updated_at: datetime | None = Field(None, alias='updatedAt')
|
||||||
|
|
||||||
|
|
||||||
|
class UserNewPost(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
display_name: str = Field(alias='displayName')
|
||||||
|
avatar: str
|
||||||
|
pin: ShortPin | None = None
|
||||||
|
|
||||||
|
verified: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotification(UserNewPost):
|
||||||
|
id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class UserPost(UserNotification, UserNewPost):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserWhoToFollow(UserPost):
|
||||||
|
followers_count: int = Field(0, alias='followersCount')
|
||||||
|
|
||||||
|
|
||||||
|
class UserFollower(UserPost):
|
||||||
|
is_following: bool = Field(False, alias='isFollowing') # none for me
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearch(UserFollower, UserWhoToFollow):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserSearch, _UserPrivacy):
|
||||||
|
banner: str | None = None
|
||||||
|
bio: str | None = None
|
||||||
|
pinned_post_id: UUID | None = Field(None, alias='pinnedPostId')
|
||||||
|
|
||||||
|
following_count: int = Field(0, alias='followingCount')
|
||||||
|
posts_count: int = Field(0, alias='postsCount')
|
||||||
|
|
||||||
|
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
|
||||||
23
itd/models/verification.py
Normal file
23
itd/models/verification.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class Verification(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID = Field(alias='userId')
|
||||||
|
video_url: str = Field(alias='videoUrl')
|
||||||
|
status: str # should be enum, but we dont know all statuses (what status for accepted?)
|
||||||
|
|
||||||
|
reject_reason: str | None = Field(None, alias='rejectionReason')
|
||||||
|
reviewer: str | None = Field(None, alias='reviewedBy')
|
||||||
|
reviewed_at: datetime | None = Field(None, alias='reviewedAt')
|
||||||
|
|
||||||
|
created_at: datetime = Field(alias='createdAt')
|
||||||
|
updated_at: datetime = Field(alias='updatedAt')
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationStatus(BaseModel):
|
||||||
|
status: str # should be enum, but we dont know all statuses (what status for accepted?)
|
||||||
|
request_id: UUID = Field(alias='requestId')
|
||||||
|
submitted_at: datetime = Field(alias='submittedAt')
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from itd.request import fetch
|
|
||||||
|
|
||||||
def get_notifications(token: str, limit: int = 20, cursor: int = 0, type: str | None = None):
|
|
||||||
data = {'limit': str(limit), 'cursor': str(cursor)}
|
|
||||||
if type:
|
|
||||||
data['type'] = type
|
|
||||||
return fetch(token, 'get', 'notifications', data)
|
|
||||||
|
|
||||||
def mark_as_read(token: str, id: str):
|
|
||||||
return fetch(token, 'post', f'notification/{id}/read')
|
|
||||||
|
|
||||||
def mark_all_as_read(token: str):
|
|
||||||
return fetch(token, 'post', f'notification/read-all')
|
|
||||||
|
|
||||||
def get_unread_notifications_count(token: str):
|
|
||||||
return fetch(token, 'get', 'notifications/count')
|
|
||||||
42
itd/posts.py
42
itd/posts.py
@@ -1,42 +0,0 @@
|
|||||||
from itd.request import fetch
|
|
||||||
|
|
||||||
def create_post(token: str, content: str, wall_recipient_id: int | None = None, attach_ids: list[str] = []):
|
|
||||||
data: dict = {'content': content}
|
|
||||||
if wall_recipient_id:
|
|
||||||
data['wallRecipientId'] = wall_recipient_id
|
|
||||||
if attach_ids:
|
|
||||||
data['attachmentIds'] = attach_ids
|
|
||||||
|
|
||||||
return fetch(token, 'post', 'posts', data)
|
|
||||||
|
|
||||||
def get_posts(token: str, username: str | None = None, limit: int = 20, cursor: int = 0, sort: str = '', tab: str = ''):
|
|
||||||
data: dict = {'limit': limit, 'cursor': cursor}
|
|
||||||
if username:
|
|
||||||
data['username'] = username
|
|
||||||
if sort:
|
|
||||||
data['sort'] = sort
|
|
||||||
if tab:
|
|
||||||
data['tab'] = tab
|
|
||||||
|
|
||||||
return fetch(token, 'get', 'posts', data)
|
|
||||||
|
|
||||||
def get_post(token: str, id: str):
|
|
||||||
return fetch(token, 'get', f'posts/{id}')
|
|
||||||
|
|
||||||
def edit_post(token: str, id: str, content: str):
|
|
||||||
return fetch(token, 'put', f'posts/{id}', {'content': content})
|
|
||||||
|
|
||||||
def delete_post(token: str, id: str):
|
|
||||||
return fetch(token, 'delete', f'posts/{id}')
|
|
||||||
|
|
||||||
def pin_post(token: str, id: str):
|
|
||||||
return fetch(token, 'post', f'posts/{id}/pin')
|
|
||||||
|
|
||||||
def repost(token: str, id: str, content: str | None = None):
|
|
||||||
data = {}
|
|
||||||
if content:
|
|
||||||
data['content'] = content
|
|
||||||
return fetch(token, 'post', f'posts/{id}/repost', data)
|
|
||||||
|
|
||||||
def view_post(token: str, id: str):
|
|
||||||
return fetch(token, 'post', f'posts/{id}/view')
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from itd.request import fetch
|
|
||||||
|
|
||||||
def report(token: str, id: str, type: str = 'post', reason: str = 'other', description: str = ''):
|
|
||||||
return fetch(token, 'post', 'reports', {'targetId': id, 'targetType': type, 'reason': reason, 'description': description})
|
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
|
from _io import BufferedReader
|
||||||
|
|
||||||
from requests import Session
|
from requests import Session
|
||||||
|
from requests.exceptions import JSONDecodeError
|
||||||
|
|
||||||
|
from itd.exceptions import InvalidToken, InvalidCookie, RateLimitExceeded, Unauthorized, AccountBanned, ProfileRequired
|
||||||
|
|
||||||
s = Session()
|
s = Session()
|
||||||
|
|
||||||
|
|
||||||
def fetch(token: str, method: str, url: str, params: dict = {}):
|
def fetch(token: str, method: str, url: str, params: dict = {}, files: dict[str, tuple[str, BufferedReader]] = {}):
|
||||||
res = eval(f's.{method}')(f'https://xn--d1ah4a.com/api/{url}', timeout=20, params=params, headers={
|
base = f'https://xn--d1ah4a.com/api/{url}'
|
||||||
|
headers = {
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
"Accept-Language": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3",
|
"Accept-Language": "ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3",
|
||||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||||
@@ -19,17 +25,49 @@ def fetch(token: str, method: str, url: str, params: dict = {}):
|
|||||||
"Pragma": "no-cache",
|
"Pragma": "no-cache",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"TE": "trailers"
|
"TE": "trailers"
|
||||||
})
|
}
|
||||||
res.raise_for_status()
|
method = method.lower()
|
||||||
return res.json()
|
if method == "get":
|
||||||
|
res = s.get(base, timeout=120 if files else 20, params=params, headers=headers)
|
||||||
|
else:
|
||||||
|
res = s.request(method.upper(), base, timeout=120 if files else 20, json=params, headers=headers, files=files)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if res.json().get('error') == 'Too Many Requests':
|
||||||
|
raise RateLimitExceeded(0)
|
||||||
|
if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED':
|
||||||
|
raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0))
|
||||||
|
if res.json().get('error', {}).get('code') == 'UNAUTHORIZED':
|
||||||
|
raise Unauthorized()
|
||||||
|
if res.json().get('error', {}).get('code') in ('ACCOUNT_BANNED', 'USER_BLOCKED'):
|
||||||
|
raise AccountBanned()
|
||||||
|
if res.json().get('error', {}).get('code') == 'PROFILE_REQUIRED':
|
||||||
|
raise ProfileRequired()
|
||||||
|
except (JSONDecodeError, AttributeError):
|
||||||
|
pass # todo
|
||||||
|
|
||||||
|
if not res.ok:
|
||||||
|
print(res.text)
|
||||||
|
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):
|
def set_cookies(cookies: str):
|
||||||
for cookie in cookies.split('; '):
|
for cookie in cookies.split('; '):
|
||||||
s.cookies.set(cookie.split('=')[0], cookie.split('=')[-1], path='/', domain='xn--d1ah4a.com.com')
|
s.cookies.set(cookie.split('=')[0], cookie.split('=')[-1], path='/', domain='xn--d1ah4a.com.com')
|
||||||
|
|
||||||
def refresh_auth(cookies: str):
|
def auth_fetch(cookies: str, method: str, url: str, params: dict = {}, token: str | None = None):
|
||||||
print('refresh')
|
headers = {
|
||||||
res = s.post(f'https://xn--d1ah4a.com/api/v1/auth/refresh', timeout=10, headers={
|
|
||||||
"Host": "xn--d1ah4a.com",
|
"Host": "xn--d1ah4a.com",
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
@@ -49,6 +87,27 @@ def refresh_auth(cookies: str):
|
|||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Content-Length": "0",
|
"Content-Length": "0",
|
||||||
"TE": "trailers",
|
"TE": "trailers",
|
||||||
})
|
}
|
||||||
res.raise_for_status()
|
if token:
|
||||||
return res.json()['accessToken']
|
headers['Authorization'] = 'Bearer ' + token
|
||||||
|
|
||||||
|
if method == 'get':
|
||||||
|
res = s.get(f'https://xn--d1ah4a.com/api/{url}', timeout=20, params=params, headers=headers)
|
||||||
|
else:
|
||||||
|
res = s.request(method, f'https://xn--d1ah4a.com/api/{url}', timeout=20, json=params, headers=headers)
|
||||||
|
|
||||||
|
if res.text == 'UNAUTHORIZED':
|
||||||
|
raise InvalidToken()
|
||||||
|
try:
|
||||||
|
if res.json().get('error') == 'Too Many Requests':
|
||||||
|
raise RateLimitExceeded(0)
|
||||||
|
if res.json().get('error', {}).get('code') == 'RATE_LIMIT_EXCEEDED':
|
||||||
|
raise RateLimitExceeded(res.json()['error'].get('retryAfter', 0))
|
||||||
|
if res.json().get('error', {}).get('code') in ('SESSION_NOT_FOUND', 'REFRESH_TOKEN_MISSING', 'SESSION_REVOKED', 'SESSION_EXPIRED'):
|
||||||
|
raise InvalidCookie(res.json()['error']['code'])
|
||||||
|
if res.json().get('error', {}).get('code') == 'UNAUTHORIZED':
|
||||||
|
raise Unauthorized()
|
||||||
|
except JSONDecodeError:
|
||||||
|
print('fail to parse json')
|
||||||
|
|
||||||
|
return res
|
||||||
|
|||||||
0
itd/routes/__init__.py
Normal file
0
itd/routes/__init__.py
Normal file
12
itd/routes/auth.py
Normal file
12
itd/routes/auth.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from requests import Response
|
||||||
|
|
||||||
|
from itd.request import auth_fetch
|
||||||
|
|
||||||
|
def refresh_token(cookies: str) -> Response:
|
||||||
|
return auth_fetch(cookies, 'post', 'v1/auth/refresh')
|
||||||
|
|
||||||
|
def change_password(cookies: str, token: str, old: str, new: str) -> Response:
|
||||||
|
return auth_fetch(cookies, 'post', 'v1/auth/change-password', {'newPassword': new, 'oldPassword': old}, token)
|
||||||
|
|
||||||
|
def logout(cookies: str) -> Response:
|
||||||
|
return auth_fetch(cookies, 'post', 'v1/auth/logout')
|
||||||
24
itd/routes/comments.py
Normal file
24
itd/routes/comments.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from itd.request import fetch
|
||||||
|
|
||||||
|
def add_comment(token: str, post_id: UUID, content: str, attachment_ids: list[UUID] = []):
|
||||||
|
return fetch(token, 'post', f'posts/{post_id}/comments', {'content': content, "attachmentIds": list(map(str, attachment_ids))})
|
||||||
|
|
||||||
|
def add_reply_comment(token: str, comment_id: UUID, content: str, author_id: UUID, attachment_ids: list[UUID] = []):
|
||||||
|
return fetch(token, 'post', f'comments/{comment_id}/replies', {'content': content, 'replyToUserId': str(author_id), "attachmentIds": list(map(str, attachment_ids))})
|
||||||
|
|
||||||
|
def get_comments(token: str, post_id: UUID, limit: int = 20, cursor: int = 0, sort: str = 'popular'):
|
||||||
|
return fetch(token, 'get', f'posts/{post_id}/comments', {'limit': limit, 'sort': sort, 'cursor': cursor})
|
||||||
|
|
||||||
|
def like_comment(token: str, comment_id: UUID):
|
||||||
|
return fetch(token, 'post', f'comments/{comment_id}/like')
|
||||||
|
|
||||||
|
def unlike_comment(token: str, comment_id: UUID):
|
||||||
|
return fetch(token, 'delete', f'comments/{comment_id}/like')
|
||||||
|
|
||||||
|
def delete_comment(token: str, comment_id: UUID):
|
||||||
|
return fetch(token, 'delete', f'comments/{comment_id}')
|
||||||
|
|
||||||
|
def get_replies(token: str, comment_id: UUID, page: int = 1, limit: int = 50, sort: str = 'oldest'):
|
||||||
|
return fetch(token, 'get', f'comments/{comment_id}/replies', {'page': page, 'limit': limit, 'sort': sort})
|
||||||
14
itd/routes/files.py
Normal file
14
itd/routes/files.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from _io import BufferedReader
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from itd.request import fetch
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(token: str, name: str, data: BufferedReader):
|
||||||
|
return fetch(token, 'post', 'files/upload', files={'file': (name, data)})
|
||||||
|
|
||||||
|
def get_file(token: str, id: UUID):
|
||||||
|
return fetch(token, 'get', f'files/{id}')
|
||||||
|
|
||||||
|
def delete_file(token: str, id: UUID):
|
||||||
|
return fetch(token, 'delete', f'files/{id}')
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
from uuid import UUID
|
||||||
from itd.request import fetch
|
from itd.request import fetch
|
||||||
|
|
||||||
def get_hastags(token: str, limit: int = 10):
|
def get_hashtags(token: str, limit: int = 10):
|
||||||
return fetch(token, 'get', 'hashtags/trending', {'limit': limit})
|
return fetch(token, 'get', 'hashtags/trending', {'limit': limit})
|
||||||
|
|
||||||
def get_posts_by_hastag(token: str, hashtag: str, limit: int = 20, cursor: int = 0):
|
def get_posts_by_hashtag(token: str, hashtag: str, limit: int = 20, cursor: UUID | None = None):
|
||||||
return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor})
|
return fetch(token, 'get', f'hashtags/{hashtag}/posts', {'limit': limit, 'cursor': cursor})
|
||||||
23
itd/routes/notifications.py
Normal file
23
itd/routes/notifications.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
def mark_as_read(token: str, id: UUID):
|
||||||
|
return fetch(token, 'post', f'notifications/{id}/read')
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
def stream_notifications(token: str):
|
||||||
|
"""Получить SSE поток уведомлений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: Streaming response для SSE
|
||||||
|
"""
|
||||||
|
return fetch_stream(token, 'notifications/stream')
|
||||||
10
itd/routes/pins.py
Normal file
10
itd/routes/pins.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from itd.request import fetch
|
||||||
|
|
||||||
|
def get_pins(token: str):
|
||||||
|
return fetch(token, 'get', 'users/me/pins')
|
||||||
|
|
||||||
|
def remove_pin(token: str):
|
||||||
|
return fetch(token, 'delete', 'users/me/pin')
|
||||||
|
|
||||||
|
def set_pin(token: str, slug: str):
|
||||||
|
return fetch(token, 'put', 'users/me/pin', {'slug': slug})
|
||||||
61
itd/routes/posts.py
Normal file
61
itd/routes/posts.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
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 | None = None, spans: list[dict] = [], wall_recipient_id: UUID | None = None, attachment_ids: list[UUID] = [], poll: NewPoll | None = None):
|
||||||
|
data: dict = {'content': content or ''}
|
||||||
|
if spans:
|
||||||
|
data['spans'] = spans
|
||||||
|
if wall_recipient_id:
|
||||||
|
data['wallRecipientId'] = str(wall_recipient_id)
|
||||||
|
if attachment_ids:
|
||||||
|
data['attachmentIds'] = list(map(str, attachment_ids))
|
||||||
|
if poll:
|
||||||
|
data['poll'] = poll.model_dump()
|
||||||
|
|
||||||
|
return fetch(token, 'post', 'posts', data)
|
||||||
|
|
||||||
|
def get_posts(token: str, cursor: int = 0, tab: PostsTab = PostsTab.POPULAR):
|
||||||
|
return fetch(token, 'get', 'posts', {'cursor': cursor, 'tab': tab.value})
|
||||||
|
|
||||||
|
def get_post(token: str, id: UUID):
|
||||||
|
return fetch(token, 'get', f'posts/{id}')
|
||||||
|
|
||||||
|
def edit_post(token: str, id: UUID, content: str):
|
||||||
|
return fetch(token, 'put', f'posts/{id}', {'content': content})
|
||||||
|
|
||||||
|
def delete_post(token: str, id: UUID):
|
||||||
|
return fetch(token, 'delete', f'posts/{id}')
|
||||||
|
|
||||||
|
def pin_post(token: str, id: UUID):
|
||||||
|
return fetch(token, 'post', f'posts/{id}/pin')
|
||||||
|
|
||||||
|
def repost(token: str, id: UUID, content: str | None = None):
|
||||||
|
data = {}
|
||||||
|
if content:
|
||||||
|
data['content'] = content
|
||||||
|
return fetch(token, 'post', f'posts/{id}/repost', data)
|
||||||
|
|
||||||
|
def view_post(token: str, id: UUID):
|
||||||
|
return fetch(token, 'post', f'posts/{id}/view')
|
||||||
|
|
||||||
|
def get_liked_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}/liked', {'limit': limit, 'cursor': cursor})
|
||||||
|
|
||||||
|
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, id: UUID):
|
||||||
|
return fetch(token, "post", f"posts/{id}/restore",)
|
||||||
|
|
||||||
|
def like_post(token: str, id: UUID):
|
||||||
|
return fetch(token, "post", f"posts/{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]})
|
||||||
9
itd/routes/reports.py
Normal file
9
itd/routes/reports.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from itd.request import fetch
|
||||||
|
from itd.enums import ReportTargetReason, ReportTargetType
|
||||||
|
|
||||||
|
def report(token: str, id: UUID, type: ReportTargetType = ReportTargetType.POST, reason: ReportTargetReason = ReportTargetReason.OTHER, description: str | None = None):
|
||||||
|
if description is None:
|
||||||
|
description = ''
|
||||||
|
return fetch(token, 'post', 'reports', {'targetId': str(id), 'targetType': type.value, 'reason': reason.value, 'description': description})
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
|
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):
|
||||||
return fetch(token, 'get', f'users/{username}')
|
return fetch(token, 'get', f'users/{username}')
|
||||||
|
|
||||||
def update_profile(token: str, bio: str | None = None, display_name: str | None = None, username: str | None = None):
|
def update_profile(token: str, bio: str | None = None, display_name: str | None = None, username: str | None = None, banner_id: UUID | None = None):
|
||||||
data = {}
|
data = {}
|
||||||
if bio:
|
if bio:
|
||||||
data['bio'] = bio
|
data['bio'] = bio
|
||||||
@@ -12,8 +15,21 @@ def update_profile(token: str, bio: str | None = None, display_name: str | None
|
|||||||
data['displayName'] = display_name
|
data['displayName'] = display_name
|
||||||
if username:
|
if username:
|
||||||
data['username'] = username
|
data['username'] = username
|
||||||
|
if banner_id:
|
||||||
|
data['bannerId'] = str(banner_id)
|
||||||
return fetch(token, 'put', 'users/me', data)
|
return fetch(token, 'put', 'users/me', data)
|
||||||
|
|
||||||
|
def update_privacy(token: str, wall_closed: bool = False, private: bool = False):
|
||||||
|
data = {}
|
||||||
|
if wall_closed is not None:
|
||||||
|
data['wallClosed'] = wall_closed
|
||||||
|
if private is not None:
|
||||||
|
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):
|
def follow(token: str, username: str):
|
||||||
return fetch(token, 'post', f'users/{username}/follow')
|
return fetch(token, 'post', f'users/{username}/follow')
|
||||||
|
|
||||||
@@ -25,3 +41,4 @@ def get_followers(token: str, username: str, limit: int = 30, page: int = 1):
|
|||||||
|
|
||||||
def get_following(token: str, username: str, limit: int = 30, page: int = 1):
|
def get_following(token: str, username: str, limit: int = 30, page: int = 1):
|
||||||
return fetch(token, 'get', f'users/{username}/following', {'limit': limit, 'page': page})
|
return fetch(token, 'get', f'users/{username}/following', {'limit': limit, 'page': page})
|
||||||
|
|
||||||
8
itd/routes/verification.py
Normal file
8
itd/routes/verification.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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})
|
||||||
|
|
||||||
|
def get_verification_status(token: str):
|
||||||
|
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 = "0.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"
|
"requests", "pydantic", "sseclient-py"
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pydantic==2.11.9
|
||||||
|
requests==2.32.3
|
||||||
|
sseclient-py==1.8.0
|
||||||
7
scripts/README.md
Normal file
7
scripts/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Коллекция скриптов от [@kilyabin](https://github.com/kilyabin)
|
||||||
|
|
||||||
|
Пока всего два скрипта, однако будет дополняться
|
||||||
|
|
||||||
|
Работают через аргументы командной строки (например, `--file` или `--text`)
|
||||||
|
|
||||||
|
Есть помощь при аргументе `-h`, потому - разберетесь
|
||||||
78
scripts/itd-change-banner.py
Normal file
78
scripts/itd-change-banner.py
Normal file
@@ -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()
|
||||||
80
scripts/itd-create-post.py
Normal file
80
scripts/itd-create-post.py
Normal file
@@ -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()
|
||||||
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='0.1.0',
|
version='1.2.0',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'requests'
|
'requests', 'pydantic', 'sseclient-py'
|
||||||
],
|
],
|
||||||
python_requires=">=3.9"
|
python_requires=">=3.9"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user