Compare commits

...

5 Commits

Author SHA1 Message Date
ge
347a879663 v0.3.0 2022-08-28 16:53:36 +03:00
ge
a73ffb7605 upd requirements 2022-08-28 14:13:39 +03:00
ge
467551707f feat: Replace shell env with config.ini 2022-07-07 19:46:34 +03:00
ge
cda10fde05 Bump version to 0.2.1. See README 2022-07-07 09:01:14 +03:00
ge
09e6e40744 upd TODO 2022-06-10 19:47:04 +03:00
7 changed files with 174 additions and 73 deletions

View File

@ -1,6 +0,0 @@
export MASTODON_API_URL='https://mastodon.social/api/v1'
export MASTODON_API_ACCESS_TOKEN='your access token here'
export VK_API_URL='https://api.vk.com/method'
export VK_API_VERSION='5.131'
export VK_API_ACCESS_TOKEN='your access token here'
export VK_GROUP_DOMAIN='apiclub'

3
.gitignore vendored
View File

@ -1 +1,2 @@
.env
config.toml
post_id.json

View File

@ -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 mkdir -p /opt/vk-mastodon-bridge/data
ADD . /opt/vk-mastodon-bridge
WORKDIR /opt/vk-mastodon-bridge
RUN pip install --upgrade pip && pip install --requirement requirements.txt
CMD source .env; python3 vk-mastodon-bridge.py
CMD python3 vk_toot.py

118
README.md
View File

@ -1,18 +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)
## Как это работает
Скрипт согласно заданному промежутку времени (см. ниже `POLLING_TIME`) ходит в API VK и забирает оттуда самый свежий пост, запоминает его ID в файл. Затем ходит в API Mastodon и создаёт новый пост, который содержит оригинальный текст поста, ссылку на оригинальный пост, ссылки на каждое вложение из оригинального поста.
Скрипт согласно заданному промежутку времени (см. ниже `POLLING_TIME`) ходит в
API VK и забирает оттуда самый свежий пост, запоминает его ID в файл. Затем
ходит в API Mastodon и создаёт новый пост, который содержит оригинальный текст
поста, ссылку на оригинальный пост, ссылки на каждое вложение из оригинального
поста.
## Известные проблемы/TODO
- Не учитывается длина поста. Если исходный пост не будет укладываться в
лимит символов на инстансе Mastodon'а, то пост не опубликуется. Решение:
надо обрезать текст поста в функции `build_post()`. Логика сложнее, чем
кажется, поэтому я жду озарения.
- Никак не обрабатываются вложения типов отличных от фото (`photo`) и
фотоальбома (`album`). Надо добавить хотя бы `video`.
- Указывать в списке вложений в посте только те вложения, которые не были
загружены в Mastodon. Пока формируется полный список вложений.
- Добавить поддержку `group_id`.
- Добавить флаг `SHOW_ATTACHMENTS_LIST` для возможности скрытия списка
вложений из текста.
- Добавить `mastodon.POST_CHAR_LIMIT` для обрезки текста относительно лимита
инстанса.
## Настройки и запуск
### API Mastodon
Для получения токена API Mastodon достаточно добавить приложение в своём профиле (Preferences -> Development). Из разрешений достаточно `write` для создания постов, ничего другого бридж делать не умеет.
Для получения токена API Mastodon достаточно добавить приложение в своём
профиле (Preferences -> Development). Из разрешений достаточно `write` для
создания постов, ничего другого бридж делать не умеет.
### API VK
@ -22,66 +46,94 @@
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).
### Переменные окружения
### Конфигуарация
Нужно задать все переменные окружения. Удобный способ — в файле `.env` и экспортировать в шэлл.
Конфигурация задаётся в файле `./data/config.toml` и разделена на три секции:
`mastodon`, `vk` и `bridge`. Последний содержит параметры приложения, первые
два реквизиты API. См. `./data/config.example.toml`.
| Переменная окружения | Пример | Описание |
| ----------------------------- | --------------------------------- | ------------------------------------------------------------- |
| `MASTODON_API_URL` | https://mastodon.social/api/v1 | URL адрес API Mastodon. |
| `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_API_ACCESS_TOKEN` | | Ключ API VK. |
| `VK_GROUP_DOMAIN` | apiclub | slug адрес группы/паблика VK. |
| `POLLING_TIME` | 300 | Количество секунд между получением постов. Умолчание: 300. |
#### Mastodon (`mastodon`)
* `API_URL`: URL адрес API Mastodon. Пример: **https://mastodon.social/api/v1**
* `API_ACCESS_TOKEN`: Ключ API Mastodon.
#### VK (`vk`)
* `API_URL`: **https://api.vk.com/method** URL API VK.
* `API_VERSION`: **5.131** Версия VK API.
* `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
Переименуйте `.en.example` в `.env` и отредактируйте значения в нём.
Переименуйте `config.example.toml` в `config.toml` и отредактируйте его.
```
pip -r requirements.txt
source .env
python vk-mastodon-bridge.py
pip install -r requirements.txt
python vk_toot.py
```
### Docker
Переименуйте `config.example.toml` в `config.toml` и отредактируйте его.
Сборка образа:
```
sudo docker build -t vk-mastodon-bridge:0.2.0 .
sudo docker build -t vk-toot:0.3.0 .
```
Запуск контейнера:
```
sudo docker run --detach \
--name vk-mastodon-bridge \
--volume /opt/vk-mastodon-bridge/data:/opt/vk-mastodon-bridge/data \
vk-mastodon-bridge:0.2.0
sudo docker run \
--detach \
--name vk-toot \
--volume /opt/vk-toot/data:/opt/vk-toot/data \
vk-toot:0.3.0
```
## TODO
- Не учитывается длина поста. Если исходный пост не будет укладываться в лимит символов на инстансе Mastodon'а, то неизвестно что произойдёт. Решение: надо обрезать текст поста в функции `build_post()`.
- Никак не обрабатываются вложения типов отличных от фото (`photo`) и фотоальбома (`album`).
## История изменений
### 0.3.0
- BREAKING! Приложение переименовано в vk-toot.
- BREAKING! Вместо переменных окружения теперь используется config.toml. Для
изменения параметров больше не нужна пересборка контейнера.
- Улучшено логирование.
### 0.2.1
- Исправлена публиация постов. Mastodon API мог отвечать кодом 429 из-за
слишком частых загрузок медиа. Решено через добавление задержки
`REQUEST_DELAY`.
- Исправлена ошибка при посте статуса с менее чем 4-я вложениями.
- Файл `./data/last_post_id` теперь содержит JSON и называется
`./data/post_id.json`. ID хранится в единственном поле `post_id`.
- Имя модуля теперь не содержит дефисов.
- Добавлено логирование. Лог пишется в STDOUT.
### 0.2.0
- Реализована загрузка вложений в Mastodon (до 4-х штук). Только изображения.
- Обновлён путь до приложения в Dockerfile.
- Изменено место хранения файла `last_post_id`, теперь он в директории `data/`, откуда его можно удобно монтировать как Docker volume.
- Изменено место хранения файла `last_post_id`, теперь он в директории
`data/`, откуда его можно удобно монтировать как Docker volume.
### 0.1.0

13
data/config.example.toml Normal file
View File

@ -0,0 +1,13 @@
[mastodon]
API_URL = https://mastodon.social/api/v1
API_ACCESS_TOKEN = your_access_token_here
[vk]
API_URL = https://api.vk.com/method
API_VERSION = 5.131
API_ACCESS_TOKEN = your_access_token_here
GROUP_DOMAIN = apiclub
[bridge]
POLLING_TIME = 300
REQUEST_DELAY = 1

View File

@ -1 +1,2 @@
requests
requests==2.28.1
toml==0.10.2

View File

@ -1,20 +1,34 @@
__version__ = '0.2.0'
__version__ = '0.2.1'
import os
import sys
import json
import time
import datetime
import logging
import shutil
from urllib.parse import urlparse
import urllib.parse
import requests
import toml
MASTODON_API_URL = os.environ['MASTODON_API_URL']
MASTODON_API_ACCESS_TOKEN = os.environ['MASTODON_API_ACCESS_TOKEN']
VK_API_URL = os.environ['VK_API_URL']
VK_API_VERSION = os.environ['VK_API_VERSION']
VK_API_ACCESS_TOKEN = os.environ['VK_API_ACCESS_TOKEN']
VK_GROUP_DOMAIN = os.environ['VK_GROUP_DOMAIN']
with open('./data/config.toml', 'r') as file:
config = toml.loads(file.read())
MASTODON_API_URL = config['mastodon']['API_URL']
MASTODON_API_ACCESS_TOKEN = config['mastodon']['API_ACCESS_TOKEN']
VK_API_URL = config['vk']['API_URL']
VK_API_VERSION = config['vk']['API_VERSION']
VK_API_ACCESS_TOKEN = config['vk']['API_ACCESS_TOKEN']
VK_GROUP_DOMAIN = config['vk']['GROUP_DOMAIN']
# Set up logger
log = logging.getLogger('vk_toot')
log.setLevel(logging.INFO)
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(logging.Formatter(fmt = '[%(asctime)s: %(levelname)s] %(message)s'))
log.addHandler(handler)
def get_vk_group_last_post():
"""Return dict with VK group last post data."""
@ -81,7 +95,7 @@ def get_vk_post_attachments(post_data: dict) -> list:
def download_file(url: str) -> str:
"""Save file to /tmp. Return file path"""
filename = '/tmp/' + os.path.basename(urlparse(url).path)
filename = '/tmp/' + os.path.basename(urllib.parse.urlparse(url).path)
response = requests.get(url, stream=True)
with open(filename, 'wb') as out_file:
shutil.copyfileobj(response.raw, out_file)
@ -93,6 +107,14 @@ def post_media(file: str) -> str:
headers = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN}
files = {'file': open(file,'rb')}
response = requests.post(MASTODON_API_URL + '/media', files=files, headers=headers)
log.info('Post media on Mastodon. Response: ' \
+ str(response.status_code) + ' ' + str(response.text))
# Sleep some seconds to prevent HTTP 429 response.
try:
request_delay = int(config['bridge']['REQUEST_DELAY'])
except (KeyError, TypeError):
request_delay = 1
time.sleep(request_delay)
return response
def publish_toot(post_text: str, media_ids: list):
@ -102,9 +124,13 @@ def publish_toot(post_text: str, media_ids: list):
headers = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN}
params = {'status': post_text, 'media_ids[]': media_ids}
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']
log.info('Status: ' \
+ str(response.status_code) + ' URL: ' + str(post_url))
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.
"""
post_text = get_vk_post_text(post_data)
@ -115,17 +141,21 @@ def post_toot(post_data: dict) -> str:
# Upload only first 4 photos and get media_ids
i = 0
media_ids = []
while i < 4:
log.info('Attachments: %s' % str(attachments))
attachments_count = len(attachments)
if attachments_count > 4:
attachments_count = 4
while i < attachments_count:
if list(attachments[i].keys())[0] == 'photo':
photo_url = attachments[i]['photo']
print('Download image:', photo_url) # Log
log.info('Download image: %s' % photo_url)
tmpfile = download_file(photo_url) # Download file from VK
print('Saved as:', tmpfile) # Log
print('Upload to Mastodon:', tmpfile) # Log
log.info('Image saved locally as: %s' % tmpfile)
media_id = json.loads(post_media(tmpfile).text)['id'] # Upload file to Mastodon
print('Uploaded. Media ID:', media_id) # Log
media_ids.append(media_id) # Save uploaded media IDs
print('Remove local file:', tmpfile) # Log
log.info('Remove local file: %s' % tmpfile)
os.remove(tmpfile) # Remove local file
i += 1
@ -138,43 +168,49 @@ def post_toot(post_data: dict) -> str:
# Get status text
text = post_text + '\n\n' + 'Source: ' + vk_post_url + '\n\nAttachments:\n' + post_attachments
print('Длина текста', len(text))
print(text)
# Post toot!
publish_toot(text, media_ids)
def touch_lock_file(file: str, post_id: int):
with open(file, 'w') as lock:
lock.write(str(post_id))
data = json.dumps({'post_id': str(post_id)})
lock.write(data)
def read_lock_file(file: str):
with open(file, 'r') as lock:
return int(lock.read())
data = json.loads(lock.read())
return data['post_id']
def poll():
lock_file = './data/last_post_id'
log.info('Start polling %s' % datetime.datetime.now().isoformat())
lock_file = './data/post_id.json'
if os.path.exists(lock_file):
prev_post_id = read_lock_file(lock_file)
print('Read last post ID from file:', lock_file)
log.info('Read last post ID from file: %s' % lock_file)
log.info('Last post ID: %s' % prev_post_id)
else:
prev_post_id = 0
print('Last post ID is 0')
log.info('Last post ID: 0')
while True:
post_data = get_vk_group_last_post() # raw data
post_id = post_data['response']['items'][0]['id']
# Don't post duplicates
if post_id == prev_post_id:
print('Skip posting') # Log
if int(post_id) == int(prev_post_id):
log.info('Post with VK ID %s already posted, skipping' % post_id)
else:
# Toot!
print('===> Toot! VK post ID:', post_id) # Log
post_toot(post_data)
log.info('Toot! VK post ID: {}, URL: {}'.format(
post_id, get_vk_post_url(post_data)))
toot(post_data)
touch_lock_file(lock_file, post_id)
prev_post_id = read_lock_file(lock_file)
try:
poll_time = int(os.environ['POLLING_TIME'])
poll_time = int(config['bridge']['POLLING_TIME'])
except (KeyError, TypeError):
poll_time = 300
@ -182,4 +218,9 @@ def poll():
if __name__ == '__main__':
log.info('VK-Toot started. [bridge]: {}; [mastodon].API_URL {}'.format(
dict(config['bridge']),
config['mastodon']['API_URL'],
))
# Start polling
poll()