Автопостинг музыки Вконтакте: собственный паблик за два часа и аудиокнижки по дороге

Я Люблю Модель Для Сборки. Причем, люблю настолько, что даже свой бэк с историями написал. Впрочем, одним сервером без клиентов сыт не будешь, а разработка полноценного мобильного приложения у меня как-то затянулась ввиду собственной упоротости, так что радость немного порассеялась.  А еще, я всегда хотел свой паблик Вконтакте, но так, чтобы делать ничего было не надо, а все как-нибудь само работало. И вот — наконец-то наступил тот день, когда мы сможем сложить все эти мои желания в кучу и породить на свет страничку, на которой будут автопоститься рассказы фантастов в исполнении того самого Влада Коппа

Какой — какой день, простите?

Тот самый. Прямо сегодня и прямо сейчас, у нас с вами есть целый 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

Top