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 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 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) Пример аккаунта бота: [@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
Для получения токена API Mastodon достаточно добавить приложение в своём профиле (Preferences -> Development). Из разрешений достаточно `write` для создания постов, ничего другого бридж делать не умеет. Для получения токена API Mastodon достаточно добавить приложение в своём
профиле (Preferences -> Development). Из разрешений достаточно `write` для
создания постов, ничего другого бридж делать не умеет.
### API VK ### 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 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).
### Переменные окружения ### Конфигуарация
Нужно задать все переменные окружения. Удобный способ — в файле `.env` и экспортировать в шэлл. Конфигурация задаётся в файле `./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.
| `POLLING_TIME` | 300 | Количество секунд между получением постов. Умолчание: 300. | * `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 ### Запуск без Docker
Переименуйте `.en.example` в `.env` и отредактируйте значения в нём. Переименуйте `config.example.toml` в `config.toml` и отредактируйте его.
``` ```
pip -r requirements.txt pip install -r requirements.txt
source .env python vk_toot.py
python vk-mastodon-bridge.py
``` ```
### Docker ### 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 \ 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.0 --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 ### 0.2.0
- Реализована загрузка вложений в Mastodon (до 4-х штук). Только изображения. - Реализована загрузка вложений в Mastodon (до 4-х штук). Только изображения.
- Обновлён путь до приложения в Dockerfile. - Обновлён путь до приложения в Dockerfile.
- Изменено место хранения файла `last_post_id`, теперь он в директории `data/`, откуда его можно удобно монтировать как Docker volume. - Изменено место хранения файла `last_post_id`, теперь он в директории
`data/`, откуда его можно удобно монтировать как Docker volume.
### 0.1.0 ### 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 os
import sys
import json import json
import time import time
import datetime
import logging
import shutil import shutil
from urllib.parse import urlparse import urllib.parse
import requests import requests
import toml
MASTODON_API_URL = os.environ['MASTODON_API_URL'] with open('./data/config.toml', 'r') as file:
MASTODON_API_ACCESS_TOKEN = os.environ['MASTODON_API_ACCESS_TOKEN'] config = toml.loads(file.read())
VK_API_URL = os.environ['VK_API_URL']
VK_API_VERSION = os.environ['VK_API_VERSION'] MASTODON_API_URL = config['mastodon']['API_URL']
VK_API_ACCESS_TOKEN = os.environ['VK_API_ACCESS_TOKEN'] MASTODON_API_ACCESS_TOKEN = config['mastodon']['API_ACCESS_TOKEN']
VK_GROUP_DOMAIN = os.environ['VK_GROUP_DOMAIN'] 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(): def get_vk_group_last_post():
"""Return dict with VK group last post data.""" """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: def download_file(url: str) -> str:
"""Save file to /tmp. Return file path""" """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) response = requests.get(url, stream=True)
with open(filename, 'wb') as out_file: with open(filename, 'wb') as out_file:
shutil.copyfileobj(response.raw, 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} 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)
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 return response
def publish_toot(post_text: str, media_ids: list): 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} 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']
log.info('Status: ' \
+ 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)
@ -115,17 +141,21 @@ 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 = []
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': if list(attachments[i].keys())[0] == 'photo':
photo_url = attachments[i]['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 tmpfile = download_file(photo_url) # Download file from VK
print('Saved as:', tmpfile) # Log log.info('Image saved locally as: %s' % tmpfile)
print('Upload to Mastodon:', tmpfile) # Log
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
print('Uploaded. Media ID:', media_id) # Log
media_ids.append(media_id) # Save uploaded media IDs 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 os.remove(tmpfile) # Remove local file
i += 1 i += 1
@ -138,43 +168,49 @@ 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)
def touch_lock_file(file: str, post_id: int): def touch_lock_file(file: str, post_id: int):
with open(file, 'w') as lock: 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): def read_lock_file(file: str):
with open(file, 'r') as lock: with open(file, 'r') as lock:
return int(lock.read()) data = json.loads(lock.read())
return data['post_id']
def poll(): 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): if os.path.exists(lock_file):
prev_post_id = read_lock_file(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: else:
prev_post_id = 0 prev_post_id = 0
print('Last post ID is 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
post_id = post_data['response']['items'][0]['id'] post_id = post_data['response']['items'][0]['id']
# Don't post duplicates # Don't post duplicates
if post_id == prev_post_id: if int(post_id) == int(prev_post_id):
print('Skip posting') # Log log.info('Post with VK ID %s already posted, skipping' % post_id)
else: else:
# Toot! # Toot!
print('===> Toot! VK post ID:', post_id) # Log 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)
try: try:
poll_time = int(os.environ['POLLING_TIME']) poll_time = int(config['bridge']['POLLING_TIME'])
except (KeyError, TypeError): except (KeyError, TypeError):
poll_time = 300 poll_time = 300
@ -182,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()