Публикацию в Twitter и Facebook мы уже запилили, настала очередь ВКонтакте.

.NET Core VK logo

Тут, конечно, получше дела обстоят, чем с Facebook, но местами всё равно пиздец.

Итак, наша цель - публиковать контент с сайта на стене публичной страницы (паблика) ВКонтакте. Пост должен содержать картинку, текст, хэштеги и ссылку. Выглядеть будет примерно так:

ВКонтакте, пример поста с картинкой и ссылкой

Делать будем через API ВКонтакте с реализацией на C# (из .NET Core MVC приложения).

Официальная документация весьма неплохо рассказывает о том что тут и как. Первым делом нужно создать приложение и получить через него токен доступа, который необходим для отправки запросов к API.

Платформа приложения должна быть Standalone:

ВКонтакте, новое приложение

После создания открыть его настройки и изменить через Состояние на включённое (я ещё Open API добавил, хотя и не уверен, нужно ли это вообще):

ВКонтакте, настройки приложения

Из всего этого нам нужен только ID приложения.

Теперь можно получать токен. Откройте такую ссылку в браузере:

https://oauth.vk.com/authorize
  ?v=5.73
  &client_id=YOUR-APPLICATION-ID
  &display=page
  &redirect_uri=https://oauth.vk.com/blank.html
  &scope=wall,offline,groups,photos
  &response_type=token

в которой:

  • YOUR-APPLICATION-ID - ваш ID приложения;
  • wall,offline,groups,photos - так называемый scope, то есть что приложение будет уметь делать от имени вашего аккаунта. Кстати, возможно, я ему тут лишнего наразрешал. У меня там ещё manage был, но он-то вроде точно не нужен. Обратите внимание на offline - без него токен будет со сроком действия и через какое-то время протухнет.

Ладно, открываете ссылку в браузере, ВКонтакте покажет диалог подтверждения авторизации:

ВКонтакте, авторизация приложения

По нажатию на Разрешить, откроется пустая страница, но нам нужно не её содержимое, а её адрес, вернее параметр access_token:

https://oauth.vk.com/blank.html
  #access_token=YOUR-ACCESS-TOKEN
  &expires_in=0
  &user_id=YOUR-ID

Всё, теперь можно отправлять запросы к API.

Как вообще составлять запросы, рассказывает документация. Среди параметров есть маленький, но, сука, гордый параметр версии API - v, в котором нужно обязательно указать версию API. В нормальных API, если не указана конкретная версия, по умолчанию берётся свежайшая, но API ВКонтакте к нормальным относиться не планировал, как я понимаю. Хочешь использовать последнюю версию - иди ищи её вручную.

Чтобы постить на стене паблика (от имени паблика), вы должны быть его администратором или редактором. Проверить доступ к сообществам можно таким запросом:

https://api.vk.com/method/groups.get
  ?v=5.73
  &access_token=YOUR-ACCESS-TOKEN
  &filter=admin

Пример результата:

{
  "response": {
    "count": 1,
    "items": [
      YOUR-GROUP-ID
    ]
  }
}

Я админ только в одной группе (которая и есть мой паблик), потому у меня тут только один результат. У вас может быть больше, разумеется.

На всякий случай, сравните ID из этого списка с реальным ID паблика, найти который можно открыв его страницу и нажав Записи сообщества - в адресе страницы будет ID. Весьма тривиально, не правда ли.

Теперь можно постить на стену:

https://api.vk.com/method/wall.post
  ?v=5.73
  &access_token=YOUR-ACCESS-TOKEN
  &owner_id=-YOUR-GROUP-ID
  &from_group=1
  &message=TEXT

где:

  • -YOUR-GROUP-ID - ID паблика. Здесь важно, чтобы он был со знаком -. А вот во всех остальных запросах он будет без минуса. Как эту логику понимать?
  • TEXT - текст для публикации на стене паблика.

Этот запрос должен опубликовать запись на стене паблика. Я сначала по инерции передал ID приложения, а не паблика, потому получил:

Access denied: no access to call this method

Теперь как загружать картинку. ВКонтакте не искали лёгких путей при проектировании API, потому процесс состоит из трёх шагов:

  1. Получить сервер, на который будет загружаться картинка;
  2. Загрузить картинку на этот сервер;
  3. Сохранить картинку.

Всё просто пронизано заботой о разработчике (а также логикой). Но оставим психическое здоровье создателей в стороне и перейдём к реализации этого безумия на C#.

Продраться сквозь гениальный замысел сумрачного гения ВКонтакте помогла вот эта статья, хотя там и речь про загрузку фотографии в альбом, а не на стену.

Ну так вот, сначала через метод photos.getWallUploadServer надо получить сервер для загрузки картинки (фотографии). Да, вот так, API не может само как-то разобраться в своих серверах, потому для этого нужно выполнять аж целый отдельный запрос. Вот такой:

https://api.vk.com/method/photos.getWallUploadServer
  ?v=5.73
  &access_token=YOUR-ACCESS-TOKEN
  &group_id=YOUR-GROUP-ID

В ответ придёт следующее:

{
  "response": {
    "upload_url": "https:\/\/pu.vk.com\/c847219\/upload.php?act=do_add&mid=YOUR-ID&aid=-ALBUM-ID&gid=YOUR-GROUP-ID&hash=HASH-VALUE&rhash=RHASH-VALUE&swfupload=1&api=1&wallphoto=1",
    "album_id": ALBUM-ID,
    "user_id": YOUR-ID
  }
}

Отсюда нам нужна вся строка из upload_url - на этот URL и надо отправлять картинку в POST запросе. И хотя процесс описан в документации, с реальностью это описание слегка расходится. Вот цитата оттуда:

Передайте файл на адрес upload_url, полученный в предыдущем пункте, сформировав POST-запрос с полем photo. Это поле должно содержать изображение в формате multipart/form-data.

То есть, нужно просто запилить POST запрос с полем photo, в котором будут лежать байты картинки? Окей, давайте попробуем:

// uploadServer - URL сервера, полученный на предыдущем шаге
// pathToImage - где у нас лежит файл с картинкой

// читаем содержимое файла картинки
byte[] imgdata = System.IO.File.ReadAllBytes(pathToImage);
var imageContent = new ByteArrayContent(imgdata);

// создаём тело запроса в формате multipart/form-data
var multipartContent = new MultipartFormDataContent();
// имя параметра "photo", как и просили
multipartContent.Add(imageContent, "photo");

using (var httpClient = new HttpClient())
{
    // отправляем POST запрос
    var httpResponse = await httpClient.PostAsync(uploadServer, multipartContent);
    // вот сюда придёт JSON ответа
    string httpContent = await httpResponse.Content.ReadAsStringAsync();
}

Кстати говоря, Twitter API принимает файлы таким же запросом, и там этого хватает без проблем, но API ВКонтакте - зверь особой породы.

Начнём с того, что вы получите вот такую ошибку в лицо:

The character set provided in ContentType is invalid. Cannot read content as string using an invalid character set.

Что бы это могло означать? А то, что в 2018 году сраный ВКонтач отдаёт результат в кодировке Windows-1251! И причём только вот в этом методе (в других не заметил), остальные выдают нормальный UTF-8. В документации такое поведение хуй упомянуто, то есть кодировку ответа каждого метода предлагается угадывать самостоятельно.

Ну ладно, будем работать с Windows-1251:

using (var httpClient = new HttpClient())
{
    var httpResponse = await httpClient.PostAsync(server, multipartContent);
    var httpContent = await httpResponse.Content.ReadAsByteArrayAsync();
    // вот в этой строке теперь будет JSON ответа
    string responseString = Encoding.GetEncoding(1251).GetString(
        httpContent, 0, httpContent.Length
        );
}

При сборке вы скорее всего вы получите такую ошибку:

No data is available for encoding 1251. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method.

Надо поставить NuGet пакет:

dotnet add package System.Text.Encoding.CodePages

И добавить провайдер в Startup.cs (или в Main, где у вас там):

public void ConfigureServices(IServiceCollection services)
{
    // ...

    Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
    
    // ...
}

С кодировкой справились, теперь наконец-то можно посмотреть ответ от API:

{
  "server": SERVER-ID,
  "photo": "[]",
  "hash": "SOME-HASH"
}

Ошибок нет, значит это наш результат, правильно? Смотрим в документацию:

После успешной загрузки сервер возвращает в ответе JSON-объект с полями server, photo, hash...

Ну так и есть, вот мы получили ID сервера, получили хэш, но… почему поле photo пустое? Судя по документации, там должен быть JSON-массив с информацией по фото, но почему там ничего нет? И почему ничего не сказано о том, что возвращается в случае неуспешной загрузки? Так, подождите… неужели… это бля и есть неуспешная загрузка?! И значит пустой элемент photo означает ошибку? Какое охуенное, блядь, API.

Ну так и оказалось - если вам пришёл такой результат, то API что-то не понравилось в вашем запросе, и бря удачи в поисках что именно.

Нет, серьёзно, как из [] получить текст ошибки или хоть какой-то намёк на причину? Да никак, ебитесь в рот, ваш ВКонтакт. Единственное, что оставалось делать, это гуглить вариации “ВКонтакте API пустое photo”.

Поиски неистово отягощались тем, что подавляющее большинство вопросов и примеров в интернетах посвящены PHP-реализациям неосиляторов, которые сидят на Wordpress’е и тупо шлют запросы через cURL. Кроме PHP я видел даже пару примеров на C++, но ни одного на C#. И разумеется, нигде нет никаких конкретных решений, потому что из описания проблемы у всех только кофейная гуща, разлитая внутри квадратных скобок.

Убив чуть не целый день на поиски, я уже даже не помню где встретил какой-то пример отправки файла, в котором кроме содержимого файла передавалось также и его имя(!).

Я проверил это у себя:

var multipartContent = new MultipartFormDataContent();
multipartContent.Add(imageContent, "photo", "some.png");

И таки да, это и оказалось причиной. Какого же хера это не указано в документации?! И не возвращается в качестве ошибки?! А главное, я бы может понял, если бы имя файла хоть где-то потом использовалось, но что-то я нигде не нашёл его следов после загрузки. Кроме того, для всех загружаемых файлов можно передавать одно и то же имя (например, vkontakteebanytoeapi.png), и это ни на что не повлияет. Пиздец.

…Вот теперь результат возвращается в том виде, в каком и было обещано:

{
  "server": SERVER-ID,
  "photo": "[{\"photo\":\"85602013a2:x\",\"sizes\":[[\"s\",924020981,\"9d1e\",\"dcvjUHL9SEs\",75,75],[\"m\",924020981,\"9d1f\",\"H_rKPVP2wrs\",130,130],[\"x\",924020981,\"9d20\",\"-jYMssa2aAc\",300,300],[\"o\",924020981,\"9d21\",\"o9ta1uV_0ik\",130,130],[\"p\",924020981,\"9d22\",\"ZCsZ--h1nlY\",200,200],[\"q\",924020981,\"9d23\",\"6vn_QEFgd4Y\",300,300],[\"r\",924020981,\"9d24\",\"6p4zQpQBfxU\",300,300]],\"latitude\":0,\"longitude\":0,\"kid\":\"80e1clo3ef1b24d2s6d2a6l41pe0d26b9\"}]",
  "hash": "SOME-HASH"
}

Если интересно, вот как выглядят запрос и результат в “сыром” виде (хотя всё самое интересное скрыто в <Binary body>):

Загрузка фотографии на сервер ВКонтакте, вид запроса в Proxie

Остался последний шаг - сохранить (г’споди, что за идиотизм) уже загруженную картинку в куда-то там через метод photos.saveWallPhoto, используя значения из полученных результатов отправки. Запрос такой:

https://api.vk.com/method/photos.saveWallPhoto
  ?v=5.73
  &access_token=YOUR-ACCESS-TOKEN
  &group_id=YOUR-GROUP-ID
  &photo=PHOTO-VALUE
  &server=SERVER-ID
  &hash=SOME-HASH

Результат будет выглядеть так:

{
  "response": [
    {
      "id": PHOTO-ID,
      "album_id": -14,
      "owner_id": YOUR-ID,
      "photo_75": "https://pp.userapi.com/g745593/b946739573/e366/rdjtBFuL0AU.jpg",
      "photo_130": "https://pp.userapi.com/g745593/b946739573/e367/F0YFn9dcxYw.jpg",
      "photo_604": "https://pp.userapi.com/g745593/b946739573/e368/PPKmXvdSSHc.jpg",
      "photo_807": "https://pp.userapi.com/g745593/b946739573/e369/XQ9yKDshx-k.jpg",
      "photo_1280": "https://pp.userapi.com/g745593/b946739573/e36a/OdfOPoXXz3k.jpg",
      "width": 1000,
      "height": 1000,
      "text": "",
      "date": 1521917953,
      "access_key": "n3e12036c6dOeQQ4b8"
    }
  ]
}

Почти всё готово, но напоследок ВКонтакте предлагает ещё и пособирать пазл, потому что ведь нельзя же просто так взять и вернуть финальный пригодный для использования ID картинки. Нет, требуемое значение нужно собрать руками по следующей формуле: photo[owner_id]_[id], то есть photoYOUR-ID_PHOTO-ID. И вот это уже то самое значение, которое можно использовать для публикации поста с картинкой на стене паблика:

https://api.vk.com/method/wall.post
  ?v=5.73
  &access_token=YOUR-ACCESS-TOKEN
  &owner_id=-YOUR-GROUP-ID
  &from_group=1
  &message=TEXT
  &attachments=photoYOUR-ID_PHOTO-ID

В параметр attachments можно также передать ссылку (прям втупую через запятую после фотки), но в пост она будет поставлена весьма по-уродски, так что нет, спасибо. К тому же никто не запрещает использовать ссылки в тексте поста, и они тоже будут кликабельными.

Весь C#-класс я на этот раз в статью пихать не буду, так что смотрите его отдельно.


[13.04.2018] Update: 414 Request-URI Too Long

Хочу обратить ваше внимание на разницу между GET и POST запросами. Я сначала всё делал через GET, то есть в исходниках у меня было так:

public Task<Tuple<int, string>> WallPostWithPhoto(string post, string photoID)
{
    post = WebUtility.UrlEncode(post);
    return PostToWall(post, photoID);
}

async Task<Tuple<int, string>> PostToWall(string post, string photoID)
{
    using (var httpClient = new HttpClient())
    {
        var httpResponse = await httpClient.GetAsync(
            $"{_postToWallURL}&from_group=1&message={post}&attachments={photoID}"
        );
        var httpContent = await httpResponse.Content.ReadAsStringAsync();

        return new Tuple<int, string>(
            (int)httpResponse.StatusCode,
            httpContent
            );
    }
}

И однажды я был за это наказан ошибкой:

<html>
<head><title>414 Request-URI Too Long</title></head>
<body bgcolor="white">
<center><h1>414 Request-URI Too Long</h1></center>
<hr><center>nginx/0.3.33</center>
</body>
</html>

Потому что такие вещи должны делаться через POST, а не через GET.

Исправленная версия запроса:

public Task<Tuple<int, string>> WallPostWithPhoto(string post, string photoID)
{
    var postData = new Dictionary<string, string> {
            { "v", _APIver },
            { "owner_id", $"-{_PageID}" },
            { "access_token", _AccessToken},
            { "from_group", "1" },
            { "message", post },
            { "attachments", photoID }
        };
    return PostToWall(postData);
}

async Task<Tuple<int, string>> PostToWall(Dictionary<string, string> postData)
{
    using (var httpClient = new HttpClient())
    {
        var httpResponse = await httpClient.PostAsync(
            $"{_API}{_APImethodPost}",
            new FormUrlEncodedContent(postData)
        );
        var httpContent = await httpResponse.Content.ReadAsStringAsync();

        return new Tuple<int, string>(
            (int)httpResponse.StatusCode,
            httpContent
            );
    }
}

[01.05.2018] Update: Репорт багов

Я, кстати говоря, написал ВКонтакте обо всём этом бардаке, но так как спустя месяц ничего не исправлено, то можно сделать вывод, что они просто ложили хуй.