diff --git a/bridges/Vk2Bridge.php b/bridges/Vk2Bridge.php new file mode 100644 index 00000000..0bc0879f --- /dev/null +++ b/bridges/Vk2Bridge.php @@ -0,0 +1,323 @@ + [ + 'name' => 'Короткое имя группы или профиля (из ссылки)', + 'exampleValue' => 'goblin_oper_ru', + 'required' => true + ], + 'hide_reposts' => [ + 'name' => 'Скрыть репосты', + 'type' => 'checkbox', + ] + ] + ]; + + const CONFIGURATION = [ + 'access_token' => [ + 'required' => true, + ], + ]; + + const TEST_DETECT_PARAMETERS = [ + 'https://vk.com/id1' => ['u' => 'id1'], + 'https://vk.com/groupname' => ['u' => 'groupname'], + 'https://m.vk.com/groupname' => ['u' => 'groupname'], + 'https://vk.com/groupname/anythingelse' => ['u' => 'groupname'], + 'https://vk.com/groupname?w=somethingelse' => ['u' => 'groupname'], + 'https://vk.com/with_underscore' => ['u' => 'with_underscore'], + 'https://vk.com/vk.cats' => ['u' => 'vk.cats'], + ]; + + protected $ownerNames = []; + protected $pageName; + private $urlRegex = '/vk\.com\/([\w.]+)/'; + private $rateLimitCacheKey = 'vk2_rate_limit'; + + public function getURI() + { + if (!is_null($this->getInput('u'))) { + return urljoin(static::URI, urlencode($this->getInput('u'))); + } + + return parent::getURI(); + } + + public function getName() + { + if ($this->pageName) { + return $this->pageName; + } + + return parent::getName(); + } + + public function detectParameters($url) + { + if (preg_match($this->urlRegex, $url, $matches)) { + return ['u' => $matches[1]]; + } + + return null; + } + + protected function getPostURI($post) + { + $r = 'https://vk.com/wall' . $post['owner_id'] . '_'; + if (isset($post['reply_post_id'])) { + $r .= $post['reply_post_id'] . '?reply=' . $post['id'] . '&thread=' . $post['parents_stack'][0]; + } else { + $r .= $post['id']; + } + return $r; + } + + // This function is based on SlackCoyote's vkfeed2rss + // https://github.com/em92/vkfeed2rss + protected function generateContentFromPost($post) + { + // it's what we will return + $ret = $post['text']; + + // html special characters convertion + $ret = htmlentities($ret, ENT_QUOTES | ENT_HTML401); + // change all linebreak to HTML compatible
+ $ret = nl2br($ret); + + $ret = "

$ret

"; + + // find URLs + $ret = preg_replace( + '/((https?|ftp|gopher)\:\/\/[a-zA-Z0-9\-\.]+(:[a-zA-Z0-9]*)?\/?([@\w\-\+\.\?\,\'\/&%\$#\=~\x5C])*)/', + "$1", + $ret + ); + + // find [id1|Pawel Durow] form links + $ret = preg_replace('/\[(\w+)\|([^\]]+)\]/', "$2", $ret); + + + // attachments + if (isset($post['attachments'])) { + // level 1 + foreach ($post['attachments'] as $attachment) { + if ($attachment['type'] == 'video') { + // VK videos + $title = e($attachment['video']['title']); + $photo = e($this->getImageURLWithLargestWidth($attachment['video']['image'])); + $href = "https://vk.com/video{$attachment['video']['owner_id']}_{$attachment['video']['id']}"; + $ret .= "

Video: {$title}
Video: {$title}

"; + } elseif ($attachment['type'] == 'audio') { + // VK audio + $artist = e($attachment['audio']['artist']); + $title = e($attachment['audio']['title']); + $ret .= "

Audio: {$artist} - {$title}

"; + } elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] != 'gif') { + // any doc apart of gif + $doc_url = e($attachment['doc']['url']); + $title = e($attachment['doc']['title']); + $ret .= "

Документ: {$title}

"; + } + } + // level 2 + foreach ($post['attachments'] as $attachment) { + if ($attachment['type'] == 'photo') { + // JPEG, PNG photos + // GIF in vk is a document, so, not handled as photo + $photo = e($this->getImageURLWithLargestWidth($attachment['photo']['sizes'])); + $text = e($attachment['photo']['text']); + $ret .= "

{$text}

"; + } elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] == 'gif') { + // GIF docs + $url = e($attachment['doc']['url']); + $ret .= "

"; + } elseif ($attachment['type'] == 'link') { + // links + $url = e($attachment['link']['url']); + $url = str_replace('https://m.vk.com', 'https://vk.com', $url); + $title = e($attachment['link']['title']); + if (isset($attachment['link']['photo'])) { + $photo = $this->getImageURLWithLargestWidth($attachment['link']['photo']['sizes']); + $ret .= "

{$title}
{$title}

"; + } else { + $ret .= "

{$title}

"; + } + } elseif ($attachment['type'] == 'note') { + // notes + $title = e($attachment['note']['title']); + $url = e($attachment['note']['view_url']); + $ret .= "

{$title}

"; + } elseif ($attachment['type'] == 'poll') { + // polls + $question = e($attachment['poll']['question']); + $vote_count = $attachment['poll']['votes']; + $answers = $attachment['poll']['answers']; + $ret .= "

Poll: {$question} ({$vote_count} votes)
"; + foreach ($answers as $answer) { + $text = e($answer['text']); + $votes = $answer['votes']; + $rate = $answer['rate']; + $ret .= "* {$text}: {$votes} ({$rate}%)
"; + } + $ret .= '

'; + } elseif (!in_array($attachment['type'], ['video', 'audio', 'doc'])) { + $ret .= "

Unknown attachment type: {$attachment['type']}

"; + } + } + } + + return $ret; + } + + protected function getImageURLWithLargestWidth($items) + { + usort($items, function ($a, $b) { + return $b['width'] - $a['width']; + }); + return $items[0]['url']; + } + + public function collectData() + { + if ($this->cache->get($this->rateLimitCacheKey)) { + throw new HttpException('429 Too Many Requests', 429); + } + + $u = $this->getInput('u'); + $ownerId = null; + + // getting ownerId from url + $r = preg_match('/^(club|public)(\d+)$/', $u, $matches); + if ($r) { + $ownerId = -intval($matches[2]); + } else { + $r = preg_match('/^(id)(\d+)$/', $u, $matches); + if ($r) { + $ownerId = intval($matches[2]); + } + } + + // getting owner id from API + if (is_null($ownerId)) { + $r = $this->api('groups.getById', [ + 'group_ids' => $u, + ], [100]); + if (isset($r['response'][0])) { + $ownerId = -$r['response'][0]['id']; + } else { + $r = $this->api('users.get', [ + 'user_ids' => $u, + ]); + if (count($r['response']) > 0) { + $ownerId = $r['response'][0]['id']; + } + } + } + + if (is_null($ownerId)) { + returnServerError('Could not detect owner id'); + } + + $r = $this->api('wall.get', [ + 'owner_id' => $ownerId, + 'extended' => '1', + ]); + + // preparing ownerNames dictionary + foreach ($r['response']['profiles'] as $profile) { + $this->ownerNames[$profile['id']] = $profile['first_name'] . ' ' . $profile['last_name']; + } + foreach ($r['response']['groups'] as $group) { + $this->ownerNames[-$group['id']] = $group['name']; + } + $this->generateFeed($r); + } + + protected function generateFeed($r) + { + $ownerId = 0; + + foreach ($r['response']['items'] as $post) { + if (!$ownerId) { + $ownerId = $post['owner_id']; + } + $item = new FeedItem(); + $content = $this->generateContentFromPost($post); + if (isset($post['copy_history'])) { + if ($this->getInput('hide_reposts')) { + continue; + } + $originalPost = $post['copy_history'][0]; + if ($originalPost['from_id'] < 0) { + $originalPostAuthorScreenName = 'club' . (-$originalPost['owner_id']); + } else { + $originalPostAuthorScreenName = 'id' . $originalPost['owner_id']; + } + $originalPostAuthorURI = 'https://vk.com/' . $originalPostAuthorScreenName; + $originalPostAuthorName = $this->ownerNames[$originalPost['from_id']]; + $originalPostAuthor = "$originalPostAuthorName"; + $content .= '

Репост (Пост от '; + $content .= $originalPostAuthor; + $content .= '):

'; + $content .= $this->generateContentFromPost($originalPost); + } + $item->setContent($content); + $item->setTimestamp($post['date']); + $item->setAuthor($this->ownerNames[$post['from_id']]); + $item->setTitle($this->getTitle(strip_tags($content))); + $item->setURI($this->getPostURI($post)); + + $this->items[] = $item; + } + + $this->pageName = $this->ownerNames[$ownerId]; + } + + protected function getTitle($content) + { + $content = explode('
', $content)[0]; + $content = strip_tags($content); + preg_match('/^[:\,"\w\ \p{L}\(\)\?#«»\-\–\—||&\.%\\₽\/+\;\!]+/mu', htmlspecialchars_decode($content), $result); + if (count($result) == 0) { + return 'untitled'; + } + return $result[0]; + } + + protected function api($method, array $params, $expected_error_codes = []) + { + $access_token = $this->getOption('access_token'); + if (!$access_token) { + returnServerError('You cannot run VK API methods without access_token'); + } + $params['v'] = '5.131'; + $r = json_decode( + getContents( + 'https://api.vk.com/method/' . $method . '?' . http_build_query($params), + ['Authorization: Bearer ' . $access_token] + ), + true + ); + if (isset($r['error']) && !in_array($r['error']['error_code'], $expected_error_codes)) { + if ($r['error']['error_code'] == 6) { + $this->cache->set($this->rateLimitCacheKey, true, 5); + } else if ($r['error']['error_code'] == 29) { + // wall.get has limit of 5000 requests per day + // if that limit is hit, VK returns error 29 + $this->cache->set($this->rateLimitCacheKey, true, 60 * 30); + } + returnServerError('API returned error: ' . $r['error']['error_msg'] . ' (' . $r['error']['error_code'] . ')'); + } + return $r; + } +} diff --git a/docs/10_Bridge_Specific/Vk2.md b/docs/10_Bridge_Specific/Vk2.md new file mode 100644 index 00000000..7c48ad0a --- /dev/null +++ b/docs/10_Bridge_Specific/Vk2.md @@ -0,0 +1,41 @@ +Vk2Bridge +========= + +Работа этого скрипта основана [VK API](https://dev.vk.com/reference). +По сравнению с VkBridge у этого скрипта есть свои приемущества и недостатки. + +Приемущества +------------ + +- Стабильность. + Скрипт не зависит от HTML-структуры страницы VK групп или пользователей, которые могут поменяться в любой момент. + +Недостатки +---------- + +- Требуется наличие зарегистированного в ВК пользователя. + Данный пользователь должен получить `access_token`, который используется для этого скрипта. + Подробнее в разделе "Настройка" + +- Количество запросов при выключенном кэше ограничено - [5000 запросов в сутки](https://dev.vk.com/ru/reference/roadmap#%D0%9E%D0%B3%D1%80%D0%B0%D0%BD%D0%B8%D1%87%D0%B5%D0%BD%D0%B8%D1%8F%20API%20%D0%B4%D0%BB%D1%8F%20%D0%BF%D0%BE%D0%B8%D1%81%D0%BA%D0%B0) + +Настройка +--------- + +1. Перейдите по [ссылке](https://oauth.vk.com/oauth/authorize?client_id=5149410&scope=offline&redirect_uri=https://oauth.vk.com/blank.html&display=page&response_type=token) + +2. Авторизуйтесь в приложение `my_personal_app` + +3. Получите ссылку вида `https://oauth.vk.com/blank.html#access_token=MNOGO_BUKAV&expires_in=0&user_id=123456`. + Из этой ссылки скопируйте `MNOGO_BUKAV`. + +4. В `config.ini.php` в раздел Vk2Bridge вставьте `access_token` + +``` +[Vk2Bridge] +access_token = "MNOGO_BUKAV" +``` + +Примечание: в данной инструкции используется приложение, администратор которого является [@em92](https://github.com/em92). +Допускается вместо упомянутого приложения использование своего standalone-приложения. +Для этого надо в ссылке из п.1. заменить значение `client_id` на свой.