diff --git a/Dockerfile b/Dockerfile index 2f53293..a2704d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,4 +4,4 @@ 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 source .env; python3 vk_mastodon_bridge.py diff --git a/README.md b/README.md index 4069f75..64c7ef9 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ ## Известные проблемы/TODO -- Скрипт пропускает часть постов. Пока не выяснил почему. -- Добавить логирование. - Не учитывается длина поста. Если исходный пост не будет укладываться в лимит символов на инстансе Mastodon'а, то неизвестно что произойдёт. Решение: надо обрезать текст поста в функции `build_post()`. - Никак не обрабатываются вложения типов отличных от фото (`photo`) и фотоальбома (`album`). +- Указывать в списке вложений в посте только те вложения, которые не были загружены в Mastodon. Пока формируется полный список вложений. +- Использовать конфиг вместо переменных окружения? ## Настройки и запуск @@ -39,15 +39,16 @@ Нужно задать все переменные окружения. Удобный способ — в файле `.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. | +| Переменная окружения | Умолчание | Пример | Описание | +| ----------------------------- | --------- |---------------------------------- | ---------------------------------------------- | +| `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 | Задержка получением постов из VK в секундах. | +| `REQUEST_DELAY` | 1 | 1 | Задержка загрузки медиа в Mastodon в секундах. | ### Запуск без Docker @@ -64,7 +65,7 @@ python vk-mastodon-bridge.py Сборка образа: ``` -sudo docker build -t vk-mastodon-bridge:0.2.0 . +sudo docker build -t vk-mastodon-bridge:0.2.1 . ``` Запуск контейнера: @@ -73,11 +74,19 @@ sudo docker build -t vk-mastodon-bridge:0.2.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 + vk-mastodon-bridge:0.2.1 ``` ## История изменений +### 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-х штук). Только изображения. diff --git a/vk-mastodon-bridge.py b/vk_mastodon_bridge.py similarity index 75% rename from vk-mastodon-bridge.py rename to vk_mastodon_bridge.py index 330bb59..aa74ce5 100644 --- a/vk-mastodon-bridge.py +++ b/vk_mastodon_bridge.py @@ -1,10 +1,13 @@ -__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 @@ -16,6 +19,13 @@ 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'] +# Set up logger +logger = logging.getLogger('vk_mastodon_bridge') +logger.setLevel(logging.INFO) +handler = logging.StreamHandler(stream=sys.stdout) +handler.setFormatter(logging.Formatter(fmt = '[%(asctime)s: %(levelname)s] %(message)s')) +logger.addHandler(handler) + def get_vk_group_last_post(): """Return dict with VK group last post data.""" return json.loads(requests.get(VK_API_URL + '/wall.get' \ @@ -81,7 +91,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 +103,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) + logger.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(os.environ['REQUEST_DELAY']) + except (KeyError, TypeError): + request_delay = 1 + time.sleep(request_delay) return response def publish_toot(post_text: str, media_ids: list): @@ -102,6 +120,9 @@ 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) + post_url = json.loads(response.text)['url'] + logger.info('Publish status. Response: ' \ + + str(response.status_code) + ' Status: ' + str(post_url)) return response def post_toot(post_data: dict) -> str: @@ -115,17 +136,21 @@ def post_toot(post_data: dict) -> str: # Upload only first 4 photos and get media_ids i = 0 media_ids = [] - while i < 4: + logger.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 + logger.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 + logger.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 + logger.info('Remove local file: %s' % tmpfile) os.remove(tmpfile) # Remove local file i += 1 @@ -144,31 +169,35 @@ def post_toot(post_data: dict) -> str: 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' + logger.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) + logger.info('Read last post ID from file: %s' % lock_file) + logger.info('Last post ID: %s' % prev_post_id) else: prev_post_id = 0 - print('Last post ID is 0') + logger.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): + logger.info('Post with VK ID %s already posted, skipping' % post_id) else: # Toot! - print('===> Toot! VK post ID:', post_id) # Log + logger.info('Toot! VK post ID: %s' % post_id) post_toot(post_data) touch_lock_file(lock_file, post_id)