From 12134e4bb81d0c0664d2986f1b555fc7a725d975 Mon Sep 17 00:00:00 2001 From: ge Date: Sat, 21 May 2022 02:27:14 +0300 Subject: [PATCH] init --- .env.example | 6 ++ .gitignore | 1 + Dockerfile | 7 +++ README.rst | 91 +++++++++++++++++++++++++++ requirements.txt | 1 + vk-mastodon-bridge.py | 142 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 248 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.rst create mode 100644 requirements.txt create mode 100644 vk-mastodon-bridge.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3bc729d --- /dev/null +++ b/.env.example @@ -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' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0a125f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM alpine:latest +RUN apk update && apk add python3 py3-pip +RUN mkdir -p /vk-mastodon-bridge +ADD . /vk-mastodon-bridge +WORKDIR /vk-mastodon-bridge +RUN pip install --upgrade pip && pip install --requirement requirements.txt +CMD source .env; python3 vk-mastodon-bridge.py diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..19afa87 --- /dev/null +++ b/README.rst @@ -0,0 +1,91 @@ +================== +vk-mastodon-bridge +================== + +Бридж для кросспостинга из `VK`_ в `Mastodon`_. Это приложение будет дублировать посты из паблика или публичной группы VK в аккаунт Mastodon. + +Пример аккаунта бота: `@jrokku@mas.to`_ + +.. _VK: https://vk.com/ +.. _Mastodon: https://joinmastodon.org/ +.. _jrokku@mas.to: https://mas.to/@jrokku + + +Как это работает +================ + +Скрипт согласно заданному промежутку времени (см. ниже ``POLLING_TIME``) ходит в API VK и забирает оттуда самый свежий пост, запоминает его ID в файл. Затем ходит в API Mastodon и создаёт новый пост, который содержит оригинальный текст поста, ссылку на оригинальный пост, ссылки на каждое вложение из оригинального поста. + +TODO +---- + +- Не учитывается длина поста. Если исходный пост не будет укладываться в лимит символов на инстансе Mastodon'а, то неизвестно что произойдёт. Решение: надо обрезать текст поста в функции ``build_post()``. +- Никак не обрабатываются вложения типов отличных от фото (`photo`) и фотоальбома (`album`). +- Надо первые 4-е картинки добавлять как вложения в пост в мастодоне. Для остальных вложений достаточно ссылки. + +Настройки и запуск +================== + +API Mastodon +------------ + +Для получения токена API Mastodon достаточно добавить приложение в своём профиле (Preferences -> Development). Из разрешений достаточно `write` для создания постов, ничего другого бридж делать не умеет. + +API VK +------ + +Нужно добавить приложение на https://dev.vk.com. Будет получен ``APP_ID``. + +Для получения бессрочного токена перейти по ссылке следующего вида:: + + 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`` задаёт нулевой (бесконечный) срок жизни токена. + +При переходе по ссылке произойдёт редирект на страницу с подтверждением выдачи доступа приложению и далее редирект на страницу с предупреждением. Нас интересует значение query-параметра ``access_token`` в адресной строке. + +Это всё также описано где-то в документации к API: https://dev.vk.com/api/access-token/implicit-flow-user + +Переменные окружения +-------------------- + +Нужно задать все переменные окружения. Удобный способ — в файле ``.env`` и экспортировать в шэлл. + +========================= =============================== ======================================== +Переменная окружения Пример Описание +========================= =============================== ======================================== +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. +========================= =============================== ======================================== + +Запуск +------ + +:: + + pip -r requirements.txt + source .env + python vk-mastodon-bridge.py + +Docker +------ + +:: + + sudo docker build -t vk-mastodon-bridge . + sudo docker run --detach --name vkmbridge vk-mastodon-bridge + + +История изменений +================= + +0.1.0 +----- + +Initial release. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/vk-mastodon-bridge.py b/vk-mastodon-bridge.py new file mode 100644 index 0000000..1f83b7c --- /dev/null +++ b/vk-mastodon-bridge.py @@ -0,0 +1,142 @@ +__version__ = '0.1.0' + +import os +import json +import time +import requests + + +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'] + +def get_vk_group_last_post(): + """Return dict with VK group last post data.""" + return json.loads(requests.get(VK_API_URL + '/wall.get' \ + + '?v=' + VK_API_VERSION \ + + '&access_token=' + VK_API_ACCESS_TOKEN \ + + '&domain=' + VK_GROUP_DOMAIN \ + + '&count=1').text) + +def get_vk_post_text(post_data: dict) -> str: + """See: https://dev.vk.com/reference/objects/post""" + return post_data['response']['items'][0]['text'] + +def get_vk_post_url(post_data: dict) -> str: + """Return link to original post on vk.com + See: https://dev.vk.com/reference/objects/post + """ + wall_id = str(post_data['response']['items'][0]['owner_id']) + post_id = str(post_data['response']['items'][0]['id']) + return 'https://vk.com/wall' + wall_id + '_' + post_id + +def get_vk_post_attachments(post_data: dict) -> list: + """Process attachments. See attachments at + https://dev.vk.com/method/wall.post + Return list with following structure:: + + [{'photo': 'url'}, {'album': 'url'}] + """ + attachments = [] + raw_attachments = post_data['response']['items'][0]['attachments'] + + for attachment in raw_attachments: + if attachment['type'] == 'photo': + # Get photo in max size by height (photos are proportionally resized) + height = [ photo['height'] for photo in attachment['photo']['sizes'] ] + for photo in attachment['photo']['sizes']: + if photo['height'] == max(height): + photo_url = photo['url'] + attachments.append({'photo': photo_url}) + elif attachment['type'] == 'video': + pass + elif attachment['type'] == 'audio': + pass + elif attachment['type'] == 'doc': + pass + elif attachment['type'] == 'page': + pass + elif attachment['type'] == 'note': + pass + elif attachment['type'] == 'pull': + pass + elif attachment['type'] == 'album': + owner_id = str(attachment['album']['owner_id']) + id = str(attachment['album']['id']) + album_url = 'https://vk.com/album' + owner_id + '_' + id + attachments.append({'album': album_url}) + elif attachment['type'] == 'market': + pass + elif attachment['type'] == 'market_album': + pass + elif attachment['type'] == 'audio_playlist': + pass + return attachments + +def build_post(post_data: dict) -> str: + post_text = get_vk_post_text(post_data) + vk_post_url = get_vk_post_url(post_data) + attachments = get_vk_post_attachments(post_data) + + # Build attachments list + post_attachments = '' + for attachment in attachments: + key = str(list(attachment.keys())[0]) + # Example resulting string: 'Album: https://vk.com/album-26788782_284176934' + post_attachments = post_attachments + key.title() + ': ' + attachment[key] + '\n' + return post_text + '\n\n' + 'Source: ' + vk_post_url + '\n\nAttachments:\n' + post_attachments + +def post_toot(post_text: str): + """Post toot on Mastodon. + See: https://docs.joinmastodon.org/methods/statuses/ + """ + auth = {'Authorization': 'Bearer ' + MASTODON_API_ACCESS_TOKEN} + params = {'status': post_text} + response = requests.post(MASTODON_API_URL + '/statuses', data=params, headers=auth) + return response + +def touch_lock_file(file: str, post_id: int): + with open(file, 'w') as lock: + lock.write(str(post_id)) + +def read_lock_file(file: str): + with open(file, 'r') as lock: + return int(lock.read()) + +def poll(): + lock_file = './.last_post_id.tmp' + if os.path.exists(lock_file): + prev_post_id = read_lock_file(lock_file) + print('Read last post ID from file:', lock_file) + else: + prev_post_id = 0 + print('Last post ID is 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') + else: + # Toot! + print('Toot! VK post ID:', post_id) + post = build_post(post_data) + post_toot(post) + + touch_lock_file(lock_file, post_id) + prev_post_id = read_lock_file(lock_file) + try: + poll_time = int(os.environ['POLLING_TIME']) + except (KeyError, TypeError): + poll_time = 300 + + time.sleep(poll_time) + + +if __name__ == '__main__': + poll()