mirror of
https://github.com/RSS-Bridge/rss-bridge.git
synced 2025-03-17 21:02:38 +00:00
[Vk2Bridge] Alternative bridge for VK (#3878)
This commit is contained in:
parent
8e8028b786
commit
257799be8e
2 changed files with 364 additions and 0 deletions
323
bridges/Vk2Bridge.php
Normal file
323
bridges/Vk2Bridge.php
Normal file
|
@ -0,0 +1,323 @@
|
|||
<?php
|
||||
|
||||
class Vk2Bridge extends BridgeAbstract
|
||||
{
|
||||
const MAINTAINER = 'em92';
|
||||
const NAME = 'ВКонтакте';
|
||||
const URI = 'https://vk.com';
|
||||
const DESCRIPTION = 'Выводит записи на стене';
|
||||
const CACHE_TIMEOUT = 300; // 5 minutes
|
||||
const PARAMETERS = [
|
||||
[
|
||||
'u' => [
|
||||
'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 <br />
|
||||
$ret = nl2br($ret);
|
||||
|
||||
$ret = "<p>$ret</p>";
|
||||
|
||||
// find URLs
|
||||
$ret = preg_replace(
|
||||
'/((https?|ftp|gopher)\:\/\/[a-zA-Z0-9\-\.]+(:[a-zA-Z0-9]*)?\/?([@\w\-\+\.\?\,\'\/&%\$#\=~\x5C])*)/',
|
||||
"<a href='$1'>$1</a>",
|
||||
$ret
|
||||
);
|
||||
|
||||
// find [id1|Pawel Durow] form links
|
||||
$ret = preg_replace('/\[(\w+)\|([^\]]+)\]/', "<a href='https://vk.com/$1'>$2</a>", $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 .= "<p><a href='{$href}'><img src='{$photo}' alt='Video: {$title}'><br/>Video: {$title}</a></p>";
|
||||
} elseif ($attachment['type'] == 'audio') {
|
||||
// VK audio
|
||||
$artist = e($attachment['audio']['artist']);
|
||||
$title = e($attachment['audio']['title']);
|
||||
$ret .= "<p>Audio: {$artist} - {$title}</p>";
|
||||
} 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 .= "<p><a href='{$doc_url}'>Документ: {$title}</a></p>";
|
||||
}
|
||||
}
|
||||
// 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 .= "<p><img src='{$photo}' alt='{$text}'></p>";
|
||||
} elseif ($attachment['type'] == 'doc' and $attachment['doc']['ext'] == 'gif') {
|
||||
// GIF docs
|
||||
$url = e($attachment['doc']['url']);
|
||||
$ret .= "<p><img src='{$url}'></p>";
|
||||
} 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 .= "<p><a href='{$url}'><img src='{$photo}' alt='{$title}'><br>{$title}</a></p>";
|
||||
} else {
|
||||
$ret .= "<p><a href='{$url}'>{$title}</a></p>";
|
||||
}
|
||||
} elseif ($attachment['type'] == 'note') {
|
||||
// notes
|
||||
$title = e($attachment['note']['title']);
|
||||
$url = e($attachment['note']['view_url']);
|
||||
$ret .= "<p><a href='{$url}'>{$title}</a></p>";
|
||||
} elseif ($attachment['type'] == 'poll') {
|
||||
// polls
|
||||
$question = e($attachment['poll']['question']);
|
||||
$vote_count = $attachment['poll']['votes'];
|
||||
$answers = $attachment['poll']['answers'];
|
||||
$ret .= "<p>Poll: {$question} ({$vote_count} votes)<br />";
|
||||
foreach ($answers as $answer) {
|
||||
$text = e($answer['text']);
|
||||
$votes = $answer['votes'];
|
||||
$rate = $answer['rate'];
|
||||
$ret .= "* {$text}: {$votes} ({$rate}%)<br />";
|
||||
}
|
||||
$ret .= '</p>';
|
||||
} elseif (!in_array($attachment['type'], ['video', 'audio', 'doc'])) {
|
||||
$ret .= "<p>Unknown attachment type: {$attachment['type']}</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "<a href='$originalPostAuthorURI'>$originalPostAuthorName</a>";
|
||||
$content .= '<p>Репост (<a href="';
|
||||
$content .= $this->getPostURI($originalPost);
|
||||
$content .= '">Пост</a> от ';
|
||||
$content .= $originalPostAuthor;
|
||||
$content .= '):</p>';
|
||||
$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('<br>', $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;
|
||||
}
|
||||
}
|
41
docs/10_Bridge_Specific/Vk2.md
Normal file
41
docs/10_Bridge_Specific/Vk2.md
Normal file
|
@ -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` на свой.
|
Loading…
Reference in a new issue