v0.3.0
This commit is contained in:
parent
a73ffb7605
commit
347a879663
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
|||||||
config.ini
|
config.toml
|
||||||
post_id.json
|
post_id.json
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
FROM alpine:latest
|
FROM alpine:3.16.2
|
||||||
|
COPY . /opt/vk-toot
|
||||||
|
WORKDIR /opt/vk-toot
|
||||||
RUN apk update && apk add python3 py3-pip
|
RUN apk update && apk add python3 py3-pip
|
||||||
RUN mkdir -p /opt/vk-mastodon-bridge
|
|
||||||
ADD . /opt/vk-mastodon-bridge
|
|
||||||
WORKDIR /opt/vk-mastodon-bridge
|
|
||||||
RUN pip install --upgrade pip && pip install --requirement requirements.txt
|
RUN pip install --upgrade pip && pip install --requirement requirements.txt
|
||||||
CMD python3 vk_mastodon_bridge.py
|
CMD python3 vk_toot.py
|
||||||
|
103
README.md
103
README.md
@ -1,25 +1,42 @@
|
|||||||
# vk-mastodon-bridge
|
# VK-Toot
|
||||||
|
|
||||||
Бридж для кросспостинга из [VK](https://vk.com) в [Mastodon](https://joinmastodon.org/). Это приложение будет дублировать посты из паблика или публичной группы VK в аккаунт Mastodon.
|
Кросспостинг из [VK](https://vk.com) в [Mastodon](https://joinmastodon.org/).
|
||||||
|
Это приложение будет дублировать посты из паблика или публичной группы VK в
|
||||||
|
аккаунт Mastodon.
|
||||||
|
|
||||||
Пример аккаунта бота: [@jrokku@mas.to](https://mas.to/@jrokku)
|
Пример аккаунта бота: [@jrokku@mas.to](https://mas.to/@jrokku)
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
Скрипт согласно заданному промежутку времени (см. ниже `POLLING_TIME`) ходит в API VK и забирает оттуда самый свежий пост, запоминает его ID в файл. Затем ходит в API Mastodon и создаёт новый пост, который содержит оригинальный текст поста, ссылку на оригинальный пост, ссылки на каждое вложение из оригинального поста.
|
Скрипт согласно заданному промежутку времени (см. ниже `POLLING_TIME`) ходит в
|
||||||
|
API VK и забирает оттуда самый свежий пост, запоминает его ID в файл. Затем
|
||||||
|
ходит в API Mastodon и создаёт новый пост, который содержит оригинальный текст
|
||||||
|
поста, ссылку на оригинальный пост, ссылки на каждое вложение из оригинального
|
||||||
|
поста.
|
||||||
|
|
||||||
## Известные проблемы/TODO
|
## Известные проблемы/TODO
|
||||||
|
|
||||||
- Не учитывается длина поста. Если исходный пост не будет укладываться в лимит символов на инстансе Mastodon'а, то неизвестно что произойдёт. Решение: надо обрезать текст поста в функции `build_post()`.
|
- Не учитывается длина поста. Если исходный пост не будет укладываться в
|
||||||
- Никак не обрабатываются вложения типов отличных от фото (`photo`) и фотоальбома (`album`). Надо добавить хотя бы `video`.
|
лимит символов на инстансе Mastodon'а, то пост не опубликуется. Решение:
|
||||||
- Указывать в списке вложений в посте только те вложения, которые не были загружены в Mastodon. Пока формируется полный список вложений.
|
надо обрезать текст поста в функции `build_post()`. Логика сложнее, чем
|
||||||
|
кажется, поэтому я жду озарения.
|
||||||
|
- Никак не обрабатываются вложения типов отличных от фото (`photo`) и
|
||||||
|
фотоальбома (`album`). Надо добавить хотя бы `video`.
|
||||||
|
- Указывать в списке вложений в посте только те вложения, которые не были
|
||||||
|
загружены в Mastodon. Пока формируется полный список вложений.
|
||||||
- Добавить поддержку `group_id`.
|
- Добавить поддержку `group_id`.
|
||||||
|
- Добавить флаг `SHOW_ATTACHMENTS_LIST` для возможности скрытия списка
|
||||||
|
вложений из текста.
|
||||||
|
- Добавить `mastodon.POST_CHAR_LIMIT` для обрезки текста относительно лимита
|
||||||
|
инстанса.
|
||||||
|
|
||||||
## Настройки и запуск
|
## Настройки и запуск
|
||||||
|
|
||||||
### API Mastodon
|
### API Mastodon
|
||||||
|
|
||||||
Для получения токена API Mastodon достаточно добавить приложение в своём профиле (Preferences -> Development). Из разрешений достаточно `write` для создания постов, ничего другого бридж делать не умеет.
|
Для получения токена API Mastodon достаточно добавить приложение в своём
|
||||||
|
профиле (Preferences -> Development). Из разрешений достаточно `write` для
|
||||||
|
создания постов, ничего другого бридж делать не умеет.
|
||||||
|
|
||||||
### API VK
|
### API VK
|
||||||
|
|
||||||
@ -29,64 +46,85 @@
|
|||||||
|
|
||||||
https://oauth.vk.com/authorize?client_id=12345678&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=offline&response_type=token&v=5.131
|
https://oauth.vk.com/authorize?client_id=12345678&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=offline&response_type=token&v=5.131
|
||||||
|
|
||||||
Здесь параметр `client_id` равняется `APP_ID` приложения, `scope=offline` задаёт нулевой (бесконечный) срок жизни токена.
|
Здесь параметр `client_id` равняется `APP_ID` приложения, `scope=offline`
|
||||||
|
задаёт нулевой (бесконечный) срок жизни токена.
|
||||||
|
|
||||||
При переходе по ссылке произойдёт редирект на страницу с подтверждением выдачи доступа приложению и далее редирект на страницу с предупреждением. Нас интересует значение query-параметра `access_token` в адресной строке.
|
При переходе по ссылке произойдёт редирект на страницу с подтверждением
|
||||||
|
выдачи доступа приложению и далее редирект на страницу с предупреждением.
|
||||||
|
Нас интересует значение query-параметра `access_token` в адресной строке.
|
||||||
|
|
||||||
Это всё также описано в [документации к API](https://dev.vk.com/api/access-token/implicit-flow-user).
|
Это всё также описано в [документации к API](https://dev.vk.com/api/access-token/implicit-flow-user).
|
||||||
|
|
||||||
### Конфигуарация
|
### Конфигуарация
|
||||||
|
|
||||||
Конфигурация задаётся в файле `./data/config.ini` и разделена на три секции: `mastodon`, `vk` и `bridge`. Последний содержит параметры приложения, первые два реквизиты API. См. `./data/config.example.ini`.
|
Конфигурация задаётся в файле `./data/config.toml` и разделена на три секции:
|
||||||
|
`mastodon`, `vk` и `bridge`. Последний содержит параметры приложения, первые
|
||||||
|
два реквизиты API. См. `./data/config.example.toml`.
|
||||||
|
|
||||||
| Секция | Переменная | Умолчание | Пример | Описание |
|
#### Mastodon (`mastodon`)
|
||||||
| --------- | --------------------- | --------- |---------------------------------- | ---------------------------------------------- |
|
|
||||||
| mastodon | `API_URL` | нет | https://mastodon.social/api/v1 | URL адрес API Mastodon. |
|
* `API_URL`: URL адрес API Mastodon. Пример: **https://mastodon.social/api/v1**
|
||||||
| mastodon | `API_ACCESS_TOKEN` | нет | | Ключ API Mastodon. |
|
* `API_ACCESS_TOKEN`: Ключ API Mastodon.
|
||||||
| vk | `API_URL` | нет | https://api.vk.com/method | URL API VK. |
|
|
||||||
| vk | `API_VERSION` | нет | 5.131 | Версия VK API. |
|
#### VK (`vk`)
|
||||||
| vk | `API_ACCESS_TOKEN` | нет | | Ключ API VK. |
|
|
||||||
| vk | `GROUP_DOMAIN` | нет | apiclub | slug адрес группы/паблика VK. |
|
* `API_URL`: **https://api.vk.com/method** URL API VK.
|
||||||
| bridge | `POLLING_TIME` | 300 | 300 | Задержка получением постов из VK в секундах. |
|
* `API_VERSION`: **5.131** Версия VK API.
|
||||||
| bridge | `REQUEST_DELAY` | 1 | 1 | Задержка загрузки медиа в Mastodon в секундах. |
|
* `API_ACCESS_TOKEN`: Ключ API VK.
|
||||||
|
* `GROUP_DOMAIN`: slug адрес группы/паблика VK. Пример: **apiclub**.
|
||||||
|
|
||||||
|
#### Настройки VK-Toot (`bridge`)
|
||||||
|
|
||||||
|
* `POLLING_TIME`: **300** Задержка получения постов из VK в секундах.
|
||||||
|
* `REQUEST_DELAY`: **1** Задержка загрузки медиа в Mastodon в секундах. Если
|
||||||
|
Возникают ошибки 429 Too Many Requests, то следует увеличить эту цифру.
|
||||||
|
|
||||||
### Запуск без Docker
|
### Запуск без Docker
|
||||||
|
|
||||||
Переименуйте `config.example.ini` в `config.ini` и отредактируйте значения в нём.
|
Переименуйте `config.example.toml` в `config.toml` и отредактируйте его.
|
||||||
|
|
||||||
```
|
```
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python vk-mastodon-bridge.py
|
python vk_toot.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
|
Переименуйте `config.example.toml` в `config.toml` и отредактируйте его.
|
||||||
|
|
||||||
Сборка образа:
|
Сборка образа:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo docker build -t vk-mastodon-bridge:0.2.1 .
|
sudo docker build -t vk-toot:0.3.0 .
|
||||||
```
|
```
|
||||||
|
|
||||||
Запуск контейнера:
|
Запуск контейнера:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo docker run --detach \
|
sudo docker run \
|
||||||
--name vk-mastodon-bridge \
|
--detach \
|
||||||
--volume /opt/vk-mastodon-bridge/data:/opt/vk-mastodon-bridge/data \
|
--name vk-toot \
|
||||||
vk-mastodon-bridge:0.2.1
|
--volume /opt/vk-toot/data:/opt/vk-toot/data \
|
||||||
|
vk-toot:0.3.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## История изменений
|
## История изменений
|
||||||
|
|
||||||
### Next
|
### 0.3.0
|
||||||
|
|
||||||
- Переменные окружения заменены на config.ini.
|
- BREAKING! Приложение переименовано в vk-toot.
|
||||||
|
- BREAKING! Вместо переменных окружения теперь используется config.toml. Для
|
||||||
|
изменения параметров больше не нужна пересборка контейнера.
|
||||||
|
- Улучшено логирование.
|
||||||
|
|
||||||
### 0.2.1
|
### 0.2.1
|
||||||
|
|
||||||
- Исправлена публиация постов. Mastodon API мог отвечать кодом 429 из-за слишком частых загрузок медиа. Решено через добавление задержки `REQUEST_DELAY`.
|
- Исправлена публиация постов. Mastodon API мог отвечать кодом 429 из-за
|
||||||
|
слишком частых загрузок медиа. Решено через добавление задержки
|
||||||
|
`REQUEST_DELAY`.
|
||||||
- Исправлена ошибка при посте статуса с менее чем 4-я вложениями.
|
- Исправлена ошибка при посте статуса с менее чем 4-я вложениями.
|
||||||
- Файл `./data/last_post_id` теперь содержит JSON и называется `./data/post_id.json`. ID хранится в единственном поле `post_id`.
|
- Файл `./data/last_post_id` теперь содержит JSON и называется
|
||||||
|
`./data/post_id.json`. ID хранится в единственном поле `post_id`.
|
||||||
- Имя модуля теперь не содержит дефисов.
|
- Имя модуля теперь не содержит дефисов.
|
||||||
- Добавлено логирование. Лог пишется в STDOUT.
|
- Добавлено логирование. Лог пишется в STDOUT.
|
||||||
|
|
||||||
@ -94,7 +132,8 @@ sudo docker run --detach \
|
|||||||
|
|
||||||
- Реализована загрузка вложений в Mastodon (до 4-х штук). Только изображения.
|
- Реализована загрузка вложений в Mastodon (до 4-х штук). Только изображения.
|
||||||
- Обновлён путь до приложения в Dockerfile.
|
- Обновлён путь до приложения в Dockerfile.
|
||||||
- Изменено место хранения файла `last_post_id`, теперь он в директории `data/`, откуда его можно удобно монтировать как Docker volume.
|
- Изменено место хранения файла `last_post_id`, теперь он в директории
|
||||||
|
`data/`, откуда его можно удобно монтировать как Docker volume.
|
||||||
|
|
||||||
### 0.1.0
|
### 0.1.0
|
||||||
|
|
||||||
|
@ -1 +1,2 @@
|
|||||||
requests==2.28.1
|
requests==2.28.1
|
||||||
|
toml==0.10.2
|
||||||
|
@ -8,13 +8,13 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import configparser
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import toml
|
||||||
|
|
||||||
|
|
||||||
config = configparser.ConfigParser()
|
with open('./data/config.toml', 'r') as file:
|
||||||
config.read('./data/config.ini')
|
config = toml.loads(file.read())
|
||||||
|
|
||||||
MASTODON_API_URL = config['mastodon']['API_URL']
|
MASTODON_API_URL = config['mastodon']['API_URL']
|
||||||
MASTODON_API_ACCESS_TOKEN = config['mastodon']['API_ACCESS_TOKEN']
|
MASTODON_API_ACCESS_TOKEN = config['mastodon']['API_ACCESS_TOKEN']
|
||||||
@ -24,11 +24,11 @@ VK_API_ACCESS_TOKEN = config['vk']['API_ACCESS_TOKEN']
|
|||||||
VK_GROUP_DOMAIN = config['vk']['GROUP_DOMAIN']
|
VK_GROUP_DOMAIN = config['vk']['GROUP_DOMAIN']
|
||||||
|
|
||||||
# Set up logger
|
# Set up logger
|
||||||
logger = logging.getLogger('vk_mastodon_bridge')
|
log = logging.getLogger('vk_toot')
|
||||||
logger.setLevel(logging.INFO)
|
log.setLevel(logging.INFO)
|
||||||
handler = logging.StreamHandler(stream=sys.stdout)
|
handler = logging.StreamHandler(stream=sys.stdout)
|
||||||
handler.setFormatter(logging.Formatter(fmt = '[%(asctime)s: %(levelname)s] %(message)s'))
|
handler.setFormatter(logging.Formatter(fmt = '[%(asctime)s: %(levelname)s] %(message)s'))
|
||||||
logger.addHandler(handler)
|
log.addHandler(handler)
|
||||||
|
|
||||||
def get_vk_group_last_post():
|
def get_vk_group_last_post():
|
||||||
"""Return dict with VK group last post data."""
|
"""Return dict with VK group last post data."""
|
||||||
@ -107,7 +107,7 @@ def post_media(file: str) -> str:
|
|||||||
headers = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN}
|
headers = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN}
|
||||||
files = {'file': open(file,'rb')}
|
files = {'file': open(file,'rb')}
|
||||||
response = requests.post(MASTODON_API_URL + '/media', files=files, headers=headers)
|
response = requests.post(MASTODON_API_URL + '/media', files=files, headers=headers)
|
||||||
logger.info('Post media on Mastodon. Response: ' \
|
log.info('Post media on Mastodon. Response: ' \
|
||||||
+ str(response.status_code) + ' ' + str(response.text))
|
+ str(response.status_code) + ' ' + str(response.text))
|
||||||
# Sleep some seconds to prevent HTTP 429 response.
|
# Sleep some seconds to prevent HTTP 429 response.
|
||||||
try:
|
try:
|
||||||
@ -124,12 +124,13 @@ def publish_toot(post_text: str, media_ids: list):
|
|||||||
headers = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN}
|
headers = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN}
|
||||||
params = {'status': post_text, 'media_ids[]': media_ids}
|
params = {'status': post_text, 'media_ids[]': media_ids}
|
||||||
response = requests.post(MASTODON_API_URL + '/statuses', data=params, headers=headers)
|
response = requests.post(MASTODON_API_URL + '/statuses', data=params, headers=headers)
|
||||||
|
log.info('Publish toot. Response: %s' % response.text)
|
||||||
post_url = json.loads(response.text)['url']
|
post_url = json.loads(response.text)['url']
|
||||||
logger.info('Publish status. Response: ' \
|
log.info('Status: ' \
|
||||||
+ str(response.status_code) + ' Status: ' + str(post_url))
|
+ str(response.status_code) + ' URL: ' + str(post_url))
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def post_toot(post_data: dict) -> str:
|
def toot(post_data: dict) -> str:
|
||||||
"""Upload media files, generate status text for Mastodon post and publish.
|
"""Upload media files, generate status text for Mastodon post and publish.
|
||||||
"""
|
"""
|
||||||
post_text = get_vk_post_text(post_data)
|
post_text = get_vk_post_text(post_data)
|
||||||
@ -140,7 +141,7 @@ def post_toot(post_data: dict) -> str:
|
|||||||
# Upload only first 4 photos and get media_ids
|
# Upload only first 4 photos and get media_ids
|
||||||
i = 0
|
i = 0
|
||||||
media_ids = []
|
media_ids = []
|
||||||
logger.info('Attachments: %s' % str(attachments))
|
log.info('Attachments: %s' % str(attachments))
|
||||||
|
|
||||||
attachments_count = len(attachments)
|
attachments_count = len(attachments)
|
||||||
if attachments_count > 4:
|
if attachments_count > 4:
|
||||||
@ -149,12 +150,12 @@ def post_toot(post_data: dict) -> str:
|
|||||||
while i < attachments_count:
|
while i < attachments_count:
|
||||||
if list(attachments[i].keys())[0] == 'photo':
|
if list(attachments[i].keys())[0] == 'photo':
|
||||||
photo_url = attachments[i]['photo']
|
photo_url = attachments[i]['photo']
|
||||||
logger.info('Download image: %s' % photo_url)
|
log.info('Download image: %s' % photo_url)
|
||||||
tmpfile = download_file(photo_url) # Download file from VK
|
tmpfile = download_file(photo_url) # Download file from VK
|
||||||
logger.info('Image saved locally as: %s' % tmpfile)
|
log.info('Image saved locally as: %s' % tmpfile)
|
||||||
media_id = json.loads(post_media(tmpfile).text)['id'] # Upload file to Mastodon
|
media_id = json.loads(post_media(tmpfile).text)['id'] # Upload file to Mastodon
|
||||||
media_ids.append(media_id) # Save uploaded media IDs
|
media_ids.append(media_id) # Save uploaded media IDs
|
||||||
logger.info('Remove local file: %s' % tmpfile)
|
log.info('Remove local file: %s' % tmpfile)
|
||||||
os.remove(tmpfile) # Remove local file
|
os.remove(tmpfile) # Remove local file
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
@ -167,7 +168,8 @@ def post_toot(post_data: dict) -> str:
|
|||||||
|
|
||||||
# Get status text
|
# Get status text
|
||||||
text = post_text + '\n\n' + 'Source: ' + vk_post_url + '\n\nAttachments:\n' + post_attachments
|
text = post_text + '\n\n' + 'Source: ' + vk_post_url + '\n\nAttachments:\n' + post_attachments
|
||||||
|
print('Длина текста', len(text))
|
||||||
|
print(text)
|
||||||
# Post toot!
|
# Post toot!
|
||||||
publish_toot(text, media_ids)
|
publish_toot(text, media_ids)
|
||||||
|
|
||||||
@ -182,15 +184,15 @@ def read_lock_file(file: str):
|
|||||||
return data['post_id']
|
return data['post_id']
|
||||||
|
|
||||||
def poll():
|
def poll():
|
||||||
logger.info('Start polling %s' % datetime.datetime.now().isoformat())
|
log.info('Start polling %s' % datetime.datetime.now().isoformat())
|
||||||
lock_file = './data/post_id.json'
|
lock_file = './data/post_id.json'
|
||||||
if os.path.exists(lock_file):
|
if os.path.exists(lock_file):
|
||||||
prev_post_id = read_lock_file(lock_file)
|
prev_post_id = read_lock_file(lock_file)
|
||||||
logger.info('Read last post ID from file: %s' % lock_file)
|
log.info('Read last post ID from file: %s' % lock_file)
|
||||||
logger.info('Last post ID: %s' % prev_post_id)
|
log.info('Last post ID: %s' % prev_post_id)
|
||||||
else:
|
else:
|
||||||
prev_post_id = 0
|
prev_post_id = 0
|
||||||
logger.info('Last post ID: 0')
|
log.info('Last post ID: 0')
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
post_data = get_vk_group_last_post() # raw data
|
post_data = get_vk_group_last_post() # raw data
|
||||||
@ -198,11 +200,12 @@ def poll():
|
|||||||
|
|
||||||
# Don't post duplicates
|
# Don't post duplicates
|
||||||
if int(post_id) == int(prev_post_id):
|
if int(post_id) == int(prev_post_id):
|
||||||
logger.info('Post with VK ID %s already posted, skipping' % post_id)
|
log.info('Post with VK ID %s already posted, skipping' % post_id)
|
||||||
else:
|
else:
|
||||||
# Toot!
|
# Toot!
|
||||||
logger.info('Toot! VK post ID: %s' % post_id)
|
log.info('Toot! VK post ID: {}, URL: {}'.format(
|
||||||
post_toot(post_data)
|
post_id, get_vk_post_url(post_data)))
|
||||||
|
toot(post_data)
|
||||||
|
|
||||||
touch_lock_file(lock_file, post_id)
|
touch_lock_file(lock_file, post_id)
|
||||||
prev_post_id = read_lock_file(lock_file)
|
prev_post_id = read_lock_file(lock_file)
|
||||||
@ -215,4 +218,9 @@ def poll():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
log.info('VK-Toot started. [bridge]: {}; [mastodon].API_URL {}'.format(
|
||||||
|
dict(config['bridge']),
|
||||||
|
config['mastodon']['API_URL'],
|
||||||
|
))
|
||||||
|
# Start polling
|
||||||
poll()
|
poll()
|
Loading…
Reference in New Issue
Block a user