Compare commits

..

No commits in common. "347a87966336535c5b2a3bbe30f188a9e97e5de7" and "74dda15cff42d65443bf2f15c140822bb2ed0eed" have entirely different histories.

7 changed files with 73 additions and 174 deletions

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
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,2 +1 @@
config.toml .env
post_id.json

View File

@ -1,6 +1,7 @@
FROM alpine:3.16.2 FROM alpine:latest
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 python3 vk_toot.py CMD source .env; python3 vk-mastodon-bridge.py

118
README.md
View File

@ -1,42 +1,18 @@
# VK-Toot # vk-mastodon-bridge
Кросспостинг из [VK](https://vk.com) в [Mastodon](https://joinmastodon.org/). Бридж для кросспостинга из [VK](https://vk.com) в [Mastodon](https://joinmastodon.org/). Это приложение будет дублировать посты из паблика или публичной группы VK в аккаунт Mastodon.
Это приложение будет дублировать посты из паблика или публичной группы VK в
аккаунт Mastodon.
Пример аккаунта бота: [@jrokku@mas.to](https://mas.to/@jrokku) Пример аккаунта бота: [@jrokku@mas.to](https://mas.to/@jrokku)
## Как это работает ## Как это работает
Скрипт согласно заданному промежутку времени (см. ниже `POLLING_TIME`) ходит в Скрипт согласно заданному промежутку времени (см. ниже `POLLING_TIME`) ходит в API VK и забирает оттуда самый свежий пост, запоминает его ID в файл. Затем ходит в API Mastodon и создаёт новый пост, который содержит оригинальный текст поста, ссылку на оригинальный пост, ссылки на каждое вложение из оригинального поста.
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 достаточно добавить приложение в своём Для получения токена API Mastodon достаточно добавить приложение в своём профиле (Preferences -> Development). Из разрешений достаточно `write` для создания постов, ничего другого бридж делать не умеет.
профиле (Preferences -> Development). Из разрешений достаточно `write` для
создания постов, ничего другого бридж делать не умеет.
### API VK ### API VK
@ -46,94 +22,66 @@ API VK и забирает оттуда самый свежий пост, зап
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.toml` и разделена на три секции: Нужно задать все переменные окружения. Удобный способ — в файле `.env` и экспортировать в шэлл.
`mastodon`, `vk` и `bridge`. Последний содержит параметры приложения, первые
два реквизиты API. См. `./data/config.example.toml`.
#### Mastodon (`mastodon`) | Переменная окружения | Пример | Описание |
| ----------------------------- | --------------------------------- | ------------------------------------------------------------- |
* `API_URL`: URL адрес API Mastodon. Пример: **https://mastodon.social/api/v1** | `MASTODON_API_URL` | https://mastodon.social/api/v1 | URL адрес API Mastodon. |
* `API_ACCESS_TOKEN`: Ключ API Mastodon. | `MASTODON_API_ACCESS_TOKEN` | | Ключ API Mastodon. |
| `VK_API_URL` | https://api.vk.com/method | URL API VK. |
#### VK (`vk`) | `VK_API_VERSION` | 5.131 | Версия VK API. |
| `VK_API_ACCESS_TOKEN` | | Ключ API VK. |
* `API_URL`: **https://api.vk.com/method** URL API VK. | `VK_GROUP_DOMAIN` | apiclub | slug адрес группы/паблика VK. |
* `API_VERSION`: **5.131** Версия VK API. | `POLLING_TIME` | 300 | Количество секунд между получением постов. Умолчание: 300. |
* `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.toml` в `config.toml` и отредактируйте его. Переименуйте `.en.example` в `.env` и отредактируйте значения в нём.
``` ```
pip install -r requirements.txt pip -r requirements.txt
python vk_toot.py source .env
python vk-mastodon-bridge.py
``` ```
### Docker ### Docker
Переименуйте `config.example.toml` в `config.toml` и отредактируйте его.
Сборка образа: Сборка образа:
``` ```
sudo docker build -t vk-toot:0.3.0 . sudo docker build -t vk-mastodon-bridge:0.2.0 .
``` ```
Запуск контейнера: Запуск контейнера:
``` ```
sudo docker run \ sudo docker run --detach \
--detach \ --name vk-mastodon-bridge \
--name vk-toot \ --volume /opt/vk-mastodon-bridge/data:/opt/vk-mastodon-bridge/data \
--volume /opt/vk-toot/data:/opt/vk-toot/data \ vk-mastodon-bridge:0.2.0
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`, теперь он в директории - Изменено место хранения файла `last_post_id`, теперь он в директории `data/`, откуда его можно удобно монтировать как Docker volume.
`data/`, откуда его можно удобно монтировать как Docker volume.
### 0.1.0 ### 0.1.0

View File

@ -1,13 +0,0 @@
[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,2 +1 @@
requests==2.28.1 requests
toml==0.10.2

View File

@ -1,34 +1,20 @@
__version__ = '0.2.1' __version__ = '0.2.0'
import os import os
import sys
import json import json
import time import time
import datetime
import logging
import shutil import shutil
import urllib.parse from urllib.parse import urlparse
import requests import requests
import toml
with open('./data/config.toml', 'r') as file: MASTODON_API_URL = os.environ['MASTODON_API_URL']
config = toml.loads(file.read()) MASTODON_API_ACCESS_TOKEN = os.environ['MASTODON_API_ACCESS_TOKEN']
VK_API_URL = os.environ['VK_API_URL']
MASTODON_API_URL = config['mastodon']['API_URL'] VK_API_VERSION = os.environ['VK_API_VERSION']
MASTODON_API_ACCESS_TOKEN = config['mastodon']['API_ACCESS_TOKEN'] VK_API_ACCESS_TOKEN = os.environ['VK_API_ACCESS_TOKEN']
VK_API_URL = config['vk']['API_URL'] VK_GROUP_DOMAIN = os.environ['VK_GROUP_DOMAIN']
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."""
@ -95,7 +81,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(urllib.parse.urlparse(url).path) filename = '/tmp/' + os.path.basename(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)
@ -107,14 +93,6 @@ 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):
@ -124,13 +102,9 @@ 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 toot(post_data: dict) -> str: def post_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)
@ -141,21 +115,17 @@ def 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 = []
log.info('Attachments: %s' % str(attachments)) while i < 4:
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']
log.info('Download image: %s' % photo_url) print('Download image:', photo_url) # Log
tmpfile = download_file(photo_url) # Download file from VK tmpfile = download_file(photo_url) # Download file from VK
log.info('Image saved locally as: %s' % tmpfile) print('Saved as:', tmpfile) # Log
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
log.info('Remove local file: %s' % tmpfile) print('Remove local file:', tmpfile) # Log
os.remove(tmpfile) # Remove local file os.remove(tmpfile) # Remove local file
i += 1 i += 1
@ -168,49 +138,43 @@ def 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:
data = json.dumps({'post_id': str(post_id)}) lock.write(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:
data = json.loads(lock.read()) return int(lock.read())
return data['post_id']
def poll(): def poll():
log.info('Start polling %s' % datetime.datetime.now().isoformat()) lock_file = './data/last_post_id'
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)
log.info('Read last post ID from file: %s' % lock_file) print('Read last post ID from file:', lock_file)
log.info('Last post ID: %s' % prev_post_id)
else: else:
prev_post_id = 0 prev_post_id = 0
log.info('Last post ID: 0') print('Last post ID is 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 int(post_id) == int(prev_post_id): if post_id == prev_post_id:
log.info('Post with VK ID %s already posted, skipping' % post_id) print('Skip posting') # Log
else: else:
# Toot! # Toot!
log.info('Toot! VK post ID: {}, URL: {}'.format( print('===> Toot! VK post ID:', post_id) # Log
post_id, get_vk_post_url(post_data))) post_toot(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(config['bridge']['POLLING_TIME']) poll_time = int(os.environ['POLLING_TIME'])
except (KeyError, TypeError): except (KeyError, TypeError):
poll_time = 300 poll_time = 300
@ -218,9 +182,4 @@ 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()