Автопостинг музыки Вконтакте: собственный паблик за два часа и аудиокнижки по дороге
Я Люблю Модель Для Сборки. Причем, люблю настолько, что даже свой бэк с историями написал. Впрочем, одним сервером без клиентов сыт не будешь, а разработка полноценного мобильного приложения у меня как-то затянулась ввиду собственной упоротости, так что радость немного порассеялась. А еще, я всегда хотел свой паблик Вконтакте, но так, чтобы делать ничего было не надо, а все как-нибудь само работало. И вот — наконец-то наступил тот день, когда мы сможем сложить все эти мои желания в кучу и породить на свет страничку, на которой будут автопоститься рассказы фантастов в исполнении того самого Влада Коппа
Какой — какой день, простите?
Тот самый. Прямо сегодня и прямо сейчас, у нас с вами есть целый java-сервис, который может по запросу отдавать нам рассказ, вместе с метаданными и аудиодорожкой. А еще, у нас есть целая социальная сеть, которая позволяет постить эту самую аудиодорожку с каким-то более-менее текстовым описанием. Осталось только написать какого-нибудь демона, который будет брать рассказы с нашего с вами бэкенда, скачивать себе во временную папочку и постить их от имени паблика на стенку каждые несколько часов. Круто, правда?
Не круто. Почему не баш?
Ненавижу этот вопрос, но не устаю на него отвечать. Во-первых, баш-скрипт — это, как правило, что-то очень хрупкое, легкоранимое и слаборасширяемое. Захоти мы приписать к нему постинг не только Во Вконтакт, но и еще куда-нибудь, масштабироваться будет некуда, а код, который стягивает данные с нашего бэка придется копипастить, что не круто вдвойне.
А во-вторых, я Джава-программист и люблю писать public static void main() тем лучше, чем чаще. Отстаньте
Ладно, давай писать на твоей джаве?
Сначала, давай подумаем про архитектуру приложения. Похоже, что у нас есть два функциональных куска: стягивание пачки данных с одного сервера и аплоад этой же пачки на другой. Логично было бы построить какой-то пайплайн, который состоит из более простых шагов: тогда, в случае чего, шаги одного пайплайна, можно переиспользовать в другом и не разводить копипасты. А еще, такой пайплайн удобно запускать из любого места, легко модифицировать и быстро писать.
Впрочем, давайте, для начала, разберемся с реализацией функционала. Первое, что нам хочется сделать — это скачивать истории с собственноручно написанного бэка. И, поскольку, этот бек писали мы, надо думать, что в его апишке есть все, что нам нужно для этого:
https://mds.grakovne.org/api/v1/story/random
Например, вот такой эндпоинт, который возвращает мета-данные от рандомной истории в базе. Ни о каком отсутствии повторений в stateless модели обмена данными тут речи не идет, но 1000+ рассказов делают ситуацию более-менее сносной. Следом, наш бэк стоит дернуть за другую ниточку:
https://mds.grakovne.org/api/v1/story/{id}/audio
Разумеется, неплохо бы подставить айдишку рассказа, полученную из предыдущего запроса, и получить красивый такой mp3 файлик с аудиодорожкой
Осталось только это как-то закодить. Поскольку, когда я говорю «работать с нормальным Api», я имею в виду «пользовать Retrofit», его и будем пользовать
public interface MdsRepository { @GET("story/{id}") Call<ApiResponse<Story>> getStory(@Path("id") Integer id); @GET("story/random") Call<ApiResponse<Story>> getRandomStory(); @GET @Streaming Call<ResponseBody> getStoryAudio(@Url String url); class ApiFactory { public static MdsRepository create() { Retrofit retrofit = new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create()) .baseUrl(MDS_API_BASE_URL) .client(new OkHttpClient()) .build(); return retrofit.create(MdsRepository.class); } } }
Решение делить работу с API на слой библиотечного репозитория с доступом к данным и самописного сервиса с логикой для сетевого взаимодействия — это все еще необычно, но мне нравится: мы однозначно разделяем зоны ответственности и знаем, что если данные не пришли, виноват репозиторий, а если криво отпроцессились — сервис.
public class MdsService { private static final String STORY_TEMP_FILE_PREFIX = "mds_story_temporary_file_"; private static final String STORY_TEMP_FILE_EXTENSION = ".mp3"; private MdsRepository mdsRepository; public MdsService() { mdsRepository = MdsRepository.ApiFactory.create(); } public Story getRandomStory() { try { return mdsRepository.getRandomStory().execute().body().getBody(); } catch (Exception e) { throw new ApiException(e.getMessage()); } } public File getStoryAudio(Story story) { try { File resultFile = File.createTempFile(STORY_TEMP_FILE_PREFIX, story.getId() + STORY_TEMP_FILE_EXTENSION); ResponseBody body = mdsRepository.getStoryAudio(findStoryUrl(story)).execute().body(); FileUtils.writeByteArrayToFile(resultFile, body.bytes()); return resultFile; } catch (Exception e) { throw new ApiException(e.getMessage()); } } public void deleteStoryAudio(File file) { file.delete(); } private String findStoryUrl(Story story) { return MDS_API_BASE_URL + "story/" + story.getId() + "/audio/"; } }
Вот, кстати, и он. Все, что мы здесь умеем делать — получать мета от случайного рассказа и скачивать для него аудиодорожку в какой-то временный файлик. И, поскольку, нам от апишки больше ничего не нужно, переходим к самой интересной части развлечений
Автопостим на стенку
Раз уж для того, чтобы постить в паблик, нам нужен сам паблик, давайте создадим хотя бы его. По старинке, безо всякой там автомагизации, и даже, о ужас, вручную! Тык. Готово, место для постинга у нас есть
Осталось понять, что происходит, когда мы создаем новый пост с текстом и аудио. Для этого вооружаемся консолькой разработчика, открываем главную страничку и начинаем постить, перехватывая и анализируя запросы. И среди кучи легаси-пехапе кода даже находим то, что при должном уровне пофигизма, можно даже назвать архитектурой
Когда мы загружаем новый mp3 файл на сервер vk, мы просто передаем формочку с одним единственным файлом, а в ответ, получаем JSON-ку с кучей полезной мета-информации
{ "mid": 163130239, "gid": 0, "aid": 0, "server": 815430, "audio": "%7B%22audio%22%3A%220107fdac40%22%2C%22time%22%3A145%2C%22artist%22%3A%22%22%2C%22title%22%3A%22%22%2C%22genre%22%3A0%2C%22album%22%3A%22%22%2C%22bitrate%22%3A320%2C%22bitrate_mode%22%3A%22vbr%22%2C%22md5%22%3A%229061b719d65161986df2b90d54023659%22%2C%22md5_data_size%22%3A%225836799%22%2C%22kad%22%3A%2212000ffe800bc57f5438fabfd67c390d%22%2C%22orig_info%22%3A%7B%22uid%22%3A163130236%2C%22tag%22%3A%22498c8cda33%22%2C%22srv%22%3A813424%7D%7D", "hash": "5ec8c4fcf3cea84054c7ad720487697d" }
Здесь нас интересуют айдишник сервера, какой-то жуткий идентификатор самого ассета и mid [memberId], указывающий на автора. Все эти данные нам еще пригодятся, так что будем их собирать в dtoшку и возвращать ее из метода очередного репозитория
@POST @Multipart Call<UploadAudioResult> uploadAudio(@Url String url, @Part MultipartBody.Part file);
Сам по себе загруженный файлик ничего не значит и пока недоступен для пользователя от слова совсем. Следующее, что нам придется сделать — это приаттачить этот ассет к плейлисту. Жили бы мы сейчас в мире адтеха, сказали бы что должны создать Креатив. Но вместо этого, мы просто напишем еще один метод интерфейса
@POST("al_audio.php") @FormUrlEncoded Call<ResponseBody> attachAudio( @Field("act") String act, @Field("aid") String aid, @Field("al") String al, @Field("audio") String audio, @Field("gid") String gid, @Field("hash") String hash, @Field("mid") String mid, @Field("server") String server, @Field("upldr") String upldr );
Ух, какая куча аргументов! Здесь их действительно явно больше, чем возвращает нам предыдущий эндпоинт и половина из них просто захардкожена в коде, который формирует запрос из UI. Передаем привет разработчикам Вконтакте и запоминаем захардкодить их у себя.
Возвращает эндпоинт совершенно жуткую дичь, которая не подходит ни под один стандарт и начинается с какой-то метки
4712612219542<!><!>0<!>6077<!>3<!>67bd4f5b7211fa4632
Сразу после этого трешака, безо всяких там разделителей, идет массивчик с какими-то полезными данными. Первым значением там лежит тот же mid юзера, вторым же — айдишка самого креатива, которую от нас и будут хотеть при попытке запостить аудио на стенку
@POST("al_wall.php") @FormUrlEncoded Call<ResponseBody> postAudio( @Field("al") String al, @Field("act") String act, @Field("to_id") String toId, @Field("type") String type, @Field("hash") String hash, @Field("attach1_type") String audioType, @Field("attach1") String audioId, @Field("Message") String message );
А это, как раз, постилка. Собираем все данные в одну кучу, пишем какой-нибудь красиво отформатированный текст в message и скармливаем вконтактику. Он нам что-то возвращает, но нас это уже совершенно не интересует: пост появился и того хватит
Черт, почему просто не использовать API?
Да, у Вконтактика есть апишка. Да, там есть метод для создания нового поста в паблик. И даже java SDK есть, если что. Работы с аудио там только нет, где-то аж с моей прекрасной осени 2017, да. А поскольку, я искренне считаю, что консистентное дерьмо все-таки лучше, чем отдельно лежащий говнокод посреди хорошей архитектурки, писать аудиопроцессинг отдельно от всей остальной работы с сетью — плохо и неправильно. Так что договоримся с совестью и будем дальше имитировать запросы от UI
А где сервис?
Уже близко. Но для начала, надо решить, что делать с авторизацией. К великому счастью, для того, чтобы вкшный секъюрити-менеджер начал считать нас за своего, оказалось достаточным подсунуть одну-единственную куку в заголовок
class ApiFactory { public static VkontakteRepository create() { Configuration configuration = new Configuration(); OkHttpClient httpClient = new OkHttpClient().newBuilder().addInterceptor(chain -> { Request.Builder builder = chain.request().newBuilder(); builder.header("Cookie", "remixsid=" + configuration.getRemixId() + ";"); return chain.proceed(builder.build()); }).build(); Retrofit retrofit = new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create(new GsonBuilder().setLenient().create())) .baseUrl(VK_API_BASE_URL) .client(httpClient) .build(); return retrofit.create(VkontakteRepository.class); } }
Причем, кука эта, судя по всему, не особо стремится протухать. Зато, она отлично привязана к геолокации: переехать в другую страну без повторной авторизации не получится. Ну и черт бы с ним, давайте жить в одном месте и парсить запросы дальше
public UploadAudioResult uploadAudio(File file) { String url = configuration.getUploadUrl(); RequestBody requestFile = RequestBody.create(MediaType.parse("multipart/form-data"), file); MultipartBody.Part body = MultipartBody.Part.createFormData("file", file.getName(), requestFile); try { return repository.uploadAudio(url, body).execute().body(); } catch (Exception e) { throw new ApiException(e.getMessage()); } }
Выгрузить аудио на сервер, как и ожидалось, проще простого. Некоторые неприятности доставляет мультипарт, но в сравнении со следующим запросом — это мелочи
public AttachedAudio attachFileToPlayList(UploadAudioResult result) { try { String rawVkResponse = repository.attachAudio( "done_add", result.getAid().toString(), "1", result.getAudio(), result.getGid().toString(), result.getHash(), result.getMid().toString(), result.getServer().toString(), "1" ).execute().body().string(); return parseVkAttachResponse(result, rawVkResponse); } catch (IOException e) { throw new ApiException(e.getMessage()); } }
А вот и захардкоженные параметры завезли. Нет, я честно пытался понять, в каких случаях uploadDir становится чем-то отличным от единицы, но мои аналитические способности закончились раньше и я забил. Сломается — будем чинить
public void postStoryWall(Story story, AttachedAudio audio) { try { repository.postAudio( "1", "post", configuration.getPublicId(), "own", configuration.getWallPostHash(), "audio", formatAudioId(audio), formatStoryData(story) ).execute(); } catch (IOException e) { e.printStackTrace(); } }
Ну вот, прекрасно. Еще больше хардкода, какой-то странный форматтер метаданных и конфигуратор в придачу. Оставим это на потом и попробуем прогнать флоу в майн-классе
public static void main(String[] args) { MdsService mdsService = new MdsService(); Story story = mdsService.getRandomStory(); File file = mdsService.getStoryAudio(story); VkontakteService vkontakteService = new VkontakteService(); UploadAudioResult uploadAudioResult = vkontakteService.uploadAudio(file); AttachedAudio attachedAudio = vkontakteService.attachFileToPlayList(uploadAudioResult); vkontakteService.postStoryWall(story, attachedAudio); mdsService.deleteStoryAudio(file); }
А теперь — запускаем
Круто, рассказы худо-бедно постятся, мета-данные форматируются, а аудио вполне себе воспроизводится. Теперь будем приводить нашу мвпшечку в более-менее приличное состояние
Выносим конфиги
Просто напоминаю: куки имеют свойство протухать. И даже если вкшная кука не экспайрится по времени, ничего не мешает нам захотеть задеплоиться на сервер в каком-нибудь Таджикистане или, еще не легче, в Нидерландах, где я и так уже давно арендую крутую тачку. Да и вообще, хранить данные авторизации в коде — это как-то фу. Поэтому, завозим апачевские common-configuration и пишем враппер
public Configuration() { configs = new Configurations(); try { properties = configs.properties(new File("config.properties")); } catch (ConfigurationException cex) { throw new RuntimeException("Can't parse configuration file"); } initConfig(); } private void initConfig() { remixId = properties.getString("remix_id"); wallPostHash = properties.getString("wall_hash"); publicId = properties.getString("public_id"); uploadUrl = properties.getString("upload_url"); } public String getRemixId() { return remixId; } public String getWallPostHash() { return wallPostHash; } public String getPublicId() { return publicId; } public String getUploadUrl() { return uploadUrl; } }
Ничего необычного, я всегда так делаю: с точки зрения юзера — это обычная такая дтошка с геттерами и конструктором, а что происходит внутри и при чем тут внешние файлы — ему знать не надо, маленький еще
Где мои пайплайны?
Спокойно, мы уже реализовали всю функциональную часть, сейчас и до цепочек доберемся. Сначала, правда, нужно придумать, как передавать данные между шагами. И здесь я утащу сразу две идеи: из Андройда, где сущетвует крутой контекст, который суть — год-обджект и хранит в себе все подряд; и из похожей библиотечки от моего коллеги, которая реализует точно такие же пайплайны с шагами, только в полтора раза тяжелей и в три раза функциональнее.
public class Context { private Map<String, Object> dataSet; public Context() { dataSet = new HashMap<>(); } public void put(String key, Object data) { dataSet.put(key, data); } public void put(Class key, Object data) { dataSet.put(key.getCanonicalName(), data); } public Object get(String key) { return dataSet.get(key); } public Object get(Class key) { return dataSet.get(key.getCanonicalName()); } public void clear() { dataSet.clear(); } public void remove(String key) { dataSet.remove(key); } }
Это прекрасно. Пусть данные лежат в какой-то хешмапе, где ключ на выбор: или имя класса модели данных или — рандомная строка, если сильно приспичило. У этого подхода, на самом деле, больше проблем, чем недостатков, но для нас — сойдет
Теперь, наописываем контрактов. Первое, что мы хотим видеть в наших цепочках — элементарные шаги, на которых все строится
public interface Step { Context execute(Context context); }
Совсем элементарные, да. Причем, предполагается, что степы будут всего лишь добрасывать в контекст новые данные, но не удалять старые и не пересоздавать весь контекст целиком. Давайте, посмотрим, как это работает
public class MdsDownloadMdsStoryStep implements Step { private final static Logger LOGGER = LoggerFactory.getLogger(MdsDownloadMdsStoryStep.class); private MdsService mdsService; public MdsDownloadMdsStoryStep() { mdsService = new MdsService(); } @Override public Context execute(Context context) { LOGGER.info("Download random story from MDS Started"); Story story = mdsService.getRandomStory(); File file = mdsService.getStoryAudio(story); context.put(Story.class, story); context.put(File.class, file); LOGGER.info("Download random story from MDS Finished"); return context; } }
Задача степа — запроцессить нужные данные и сложить их в контекст. Тогда, каждый маленький шажочек сможет работать изолированно ото всех других и влезать в общую миску с модельками очень управляемо и аккуратно
Теперь, давайте распихаем весь остальной функционал по шагам и замахнемся на что-то покрупнее
public interface Chain { ChainResult execute(); }
Считается, что цепочка умеет агрегировать в себе шаги, но чисто технически, ничего не мешает завести один большой жирный метод execute и упаковать все потроха шагов в него. Это даже имеет смысл, если таска, которую решает цепочка слишком экзотична и переиспользоваться будет никогда. В остальных случаях, имеет смысл писать что-то такое
public class PublishStoryVkontakteChain implements Chain { private final static Logger LOGGER = LoggerFactory.getLogger(PublishStoryVkontakteChain.class); private final List<Step> steps; private final Context context; public PublishStoryVkontakteChain() { this.steps = new ArrayList<>(5); context = new Context(); steps.add(new MdsDownloadMdsStoryStep()); steps.add(new VkontakteUploadAudioStep()); steps.add(new VkontakteAttachAudioToPlayListStep()); steps.add(new VkontaktePublishStoryStep()); steps.add(new MdsDeleteTempFileStep()); } @Override public ChainResult execute() { LOGGER.info("Publish random story to Vkontakte Started"); try { steps.forEach(step -> step.execute(context)); } catch (Exception ex) { LOGGER.warn("Publish random story to Vkontakte Failed"); return new ChainResult(ChainResultType.FAILED, ex.getMessage()); } LOGGER.info("Publish random story to Vkontakte Finished"); return new ChainResult(ChainResultType.SUCCESSFUL); } }
С тем, как заменить эксплисит-конфигурирование цепочки на нормальное такое внедрение зависимостей, мы разберемся в другой раз, а пока — давайте перепишем наш энтрипоинт
PublishMdsStoryVkontakteChain chain = new PublishMdsStoryVkontakteChain(); ChainResult execute = chain.execute();
Уже лучше: объявили цепочку, выполнили ее и скончались. Проверим, что рассказы все еще постятся и пойдем думать еще раз
Скедулинг!
Если мы пишем сервис, который постит наши рассказы не один раз и по пинку от пользователя, а стабильно и совершенно самостоятельно, логично было бы взять — и заскедулить нашу цепочку так, чтобы она процессилась каждые… ну, скажем, два часа. Цифра из головы, не обращайте внимания.
Хорошо скедулить таски — целая наука. И, поскольку лапки у нас достаточно кривые, а велосипеды уже давно есть в свободной продаже, не будем изобретать еще один, и возьмем крутую себе библиотечку.
public class VkontaktePublishJob implements Job { private final static Logger LOGGER = LoggerFactory.getLogger(VkontaktePublishJob.class); @Override public void execute(JobExecutionContext jobExecutionContext) { LOGGER.info("Vkontakte publish job Started"); PublishStoryVkontakteChain publishStoryVkontakteChain = new PublishStoryVkontakteChain(); ChainResult result = publishStoryVkontakteChain.execute(); LOGGER.info("Vkontakte publish job Finished with result:\n {}", result); } public void config(Scheduler scheduler) throws SchedulerException { JobDetail job = JobBuilder .newJob(VkontaktePublishJob.class) .build(); Trigger trigger = TriggerBuilder .newTrigger() .startNow() .withSchedule(simpleSchedule() .withIntervalInHours(2) .repeatForever()) .build(); scheduler.scheduleJob(job, trigger); } }
Теперь, у нас есть джоба, которая умеет выполнять какую-то полезную цепочку и, что важнее, самостоятельно конфигуриться. Допишем сам шедулер и поставим жирную точку в планировании автопостинга
public class ApplicationScheduler { private final static Logger LOGGER = LoggerFactory.getLogger(ApplicationScheduler.class); private final Scheduler scheduler; public ApplicationScheduler() { try { this.scheduler = getDefaultScheduler(); configureJobs(); } catch (SchedulerException ex) { LOGGER.error("Cant' schedule jobs"); throw new RuntimeException(); } } public void reconfigureJobs() { try { scheduler.clear(); configureJobs(); } catch (SchedulerException e) { LOGGER.error("Cant' reconfigure scheduler"); throw new RuntimeException(); } } public void start() { try { scheduler.start(); } catch (SchedulerException e) { LOGGER.error("Cant' start scheduler"); throw new RuntimeException(); } } public void stop() { try { scheduler.standby(); } catch (SchedulerException e) { LOGGER.error("Cant' start scheduler"); throw new RuntimeException(); } } private void configureJobs() throws SchedulerException { new VkontaktePublishJob().config(scheduler); } }
Майн-класс теперь превратится в простую запускалку этого шедулера в отдельном потоке
public static void main(String[] args) { LOGGER.info("App Started"); ApplicationScheduler applicationScheduler = new ApplicationScheduler(); applicationScheduler.start(); }
Осталось только задеплоиться. Собираем фат-джарку, тянем ее Дженкинсом на бекенд, подсовываем в конфиг новую куку…
Идиллия: трудолюбивый робот ворочает данными, а у меня теперь есть что послушать по дороге на работу!
Исходники проекта как всегда: ссылка на GitHub
Искренне Ваш, репостящий автопосты репостов, GrakovNe