├── .gitignore ├── FeedWriter.php ├── README.md ├── Vk2rss.php ├── index.php └── utils.php /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | test* 3 | *.sh 4 | *.log 5 | *.json 6 | *.debug 7 | -------------------------------------------------------------------------------- /FeedWriter.php: -------------------------------------------------------------------------------- 1 | 17 | * @link http://www.ajaxray.com/projects/rss 18 | */ 19 | class FeedWriter 20 | { 21 | private $channels = array(); // Collection of channel elements 22 | private $items = array(); // Collection of items as object of FeedItem class. 23 | private $data = array(); // Store some other version wise data 24 | private $CDATAEncoding = array(); // The tag names which have to encoded as CDATA 25 | 26 | private $version = null; 27 | 28 | /** 29 | * Constructor 30 | * 31 | * @param $version constant the version constant (RSS1/RSS2/ATOM). 32 | */ 33 | function __construct($version = RSS2) 34 | { 35 | $this->version = $version; 36 | 37 | // Setting default value for assential channel elements 38 | $this->channels['title'] = $version . ' Feed'; 39 | $this->channels['link'] = 'http://www.ajaxray.com/blog'; 40 | 41 | //Tag names to encode in CDATA 42 | $this->CDATAEncoding = array('description', 'content:encoded', 'summary', 'title'); 43 | } 44 | 45 | // Start # public functions --------------------------------------------- 46 | 47 | /** 48 | * Set multiple channel elements from an array. Array elements 49 | * should be 'channelName' => 'channelContent' format. 50 | * 51 | * @param $elementArray array array of channels 52 | * @return void 53 | */ 54 | public function setChannelElementsFromArray($elementArray) 55 | { 56 | if (!is_array($elementArray)) return; 57 | foreach ($elementArray as $elementName => $content) { 58 | $this->setChannelElement($elementName, $content); 59 | } 60 | } 61 | 62 | /** 63 | * Set a channel element 64 | * 65 | * @param $elementName string name of the channel tag 66 | * @param $content string content of the channel tag 67 | * @return void 68 | */ 69 | public function setChannelElement($elementName, $content) 70 | { 71 | $this->channels[$elementName] = $content; 72 | } 73 | 74 | /** 75 | * Genarate the actual RSS/ATOM file 76 | * 77 | * @return void 78 | */ 79 | public function generateFeed() 80 | { 81 | // header("Content-type: text/xml"); 82 | 83 | $this->printHead(); 84 | $this->printChannels(); 85 | $this->printItems(); 86 | $this->printTale(); 87 | } 88 | 89 | /** 90 | * Prints the xml and rss namespace 91 | * 92 | * @access private 93 | * @return void 94 | */ 95 | private function printHead() 96 | { 97 | $out = '' . "\n"; 98 | 99 | if ($this->version == RSS2) { 100 | $out .= '' . PHP_EOL; 105 | } elseif ($this->version == RSS1) { 106 | $out .= '' . PHP_EOL;; 111 | } else if ($this->version == ATOM) { 112 | $out .= '' . PHP_EOL;; 113 | } 114 | echo $out; 115 | } 116 | 117 | /** 118 | * @desc Print channels 119 | * @access private 120 | * @return void 121 | */ 122 | private function printChannels() 123 | { 124 | //Start channel tag 125 | switch ($this->version) { 126 | case RSS2: 127 | echo '' . PHP_EOL; 128 | break; 129 | case RSS1: 130 | echo (isset($this->data['ChannelAbout'])) ? "data['ChannelAbout']}\">" : "channels['link']}\">"; 131 | break; 132 | } 133 | 134 | //Print Items of channel 135 | foreach ($this->channels as $key => $value) { 136 | if ($this->version == ATOM && $key == 'link') { 137 | // ATOM prints link element as href attribute 138 | echo $this->makeNode($key, '', array('href' => $value)); 139 | //Add the id for ATOM 140 | echo $this->makeNode('id', $this->uuid($value, 'urn:uuid:')); 141 | } else { 142 | echo $this->makeNode($key, $value); 143 | } 144 | 145 | } 146 | 147 | //RSS 1.0 have special tag with channel 148 | if ($this->version == RSS1) { 149 | echo "" . PHP_EOL . "" . PHP_EOL; 150 | foreach ($this->items as $item) { 151 | $thisItems = $item->getElements(); 152 | echo "" . PHP_EOL; 153 | } 154 | echo "" . PHP_EOL . "" . PHP_EOL . "" . PHP_EOL; 155 | } 156 | } 157 | 158 | 159 | // Wrapper functions ------------------------------------------------------------------- 160 | 161 | /** 162 | * Creates a single node as xml format 163 | * 164 | * @access private 165 | * @param srting name of the tag 166 | * @param mixed tag value as string or array of nested tags in 'tagName' => 'tagValue' format 167 | * @param array Attributes(if any) in 'attrName' => 'attrValue' format 168 | * @return string formatted xml tag 169 | */ 170 | private function makeNode($tagName, $tagContent, $attributes = null) 171 | { 172 | $nodeText = ''; 173 | $attrText = ''; 174 | 175 | if (is_array($attributes)) { 176 | foreach ($attributes as $key => $value) { 177 | $attrText .= " $key=\"$value\" "; 178 | } 179 | } 180 | 181 | if (is_array($tagContent) && $this->version == RSS1) { 182 | $attrText = ' rdf:parseType="Resource"'; 183 | } 184 | 185 | 186 | $attrText .= (in_array($tagName, $this->CDATAEncoding) && $this->version == ATOM) ? ' type="html" ' : ''; 187 | $nodeText .= (in_array($tagName, $this->CDATAEncoding)) ? "<{$tagName}{$attrText}>"; 188 | 189 | if (is_array($tagContent)) { 190 | foreach ($tagContent as $key => $value) { 191 | $nodeText .= $this->makeNode($key, $value); 192 | } 193 | } else { 194 | $nodeText .= (in_array($tagName, $this->CDATAEncoding)) ? 195 | $tagContent : htmlspecialchars($tagContent, defined('ENT_HTML401') ? ENT_COMPAT | ENT_HTML401 : ENT_COMPAT, "UTF-8"); 196 | } 197 | 198 | $nodeText .= (in_array($tagName, $this->CDATAEncoding)) ? "]]>" : ""; 199 | 200 | return $nodeText . PHP_EOL; 201 | } 202 | 203 | /** 204 | * Genarates an UUID 205 | * @author Anis uddin Ahmad 206 | * @param string an optional prefix 207 | * @return string the formated uuid 208 | */ 209 | public static function uuid($key = null, $prefix = '') 210 | { 211 | $key = ($key == null) ? uniqid(rand()) : $key; 212 | $chars = md5($key); 213 | $uuid = substr($chars, 0, 8) . '-'; 214 | $uuid .= substr($chars, 8, 4) . '-'; 215 | $uuid .= substr($chars, 12, 4) . '-'; 216 | $uuid .= substr($chars, 16, 4) . '-'; 217 | $uuid .= substr($chars, 20, 12); 218 | 219 | return $prefix . $uuid; 220 | } 221 | 222 | /** 223 | * Prints formatted feed items 224 | * 225 | * @access private 226 | * @return void 227 | */ 228 | private function printItems() 229 | { 230 | foreach ($this->items as $item) { 231 | $thisItems = $item->getElements(); 232 | 233 | //the argument is printed as rdf:about attribute of item in rss 1.0 234 | echo $this->startItem($thisItems['link']['content']); 235 | 236 | foreach ($thisItems as $feedItem) { 237 | if ($feedItem['name'] == 'category') { 238 | foreach ($feedItem['content'] as $tag) { 239 | echo $this->makeNode($feedItem['name'], $tag, $feedItem['attributes']); 240 | } 241 | } else { 242 | echo $this->makeNode($feedItem['name'], $feedItem['content'], $feedItem['attributes']); 243 | } 244 | } 245 | echo $this->endItem(); 246 | } 247 | } 248 | 249 | /** 250 | * Make the starting tag of channels 251 | * 252 | * @access private 253 | * @param srting The vale of about tag which is used for only RSS 1.0 254 | * @return void 255 | */ 256 | private function startItem($about = false) 257 | { 258 | if ($this->version == RSS2) { 259 | echo '' . PHP_EOL; 260 | } elseif ($this->version == RSS1) { 261 | if ($about) { 262 | echo "" . PHP_EOL; 263 | } else { 264 | die('link element is not set .\n It\'s required for RSS 1.0 to be used as about attribute of item'); 265 | } 266 | } else if ($this->version == ATOM) { 267 | echo "" . PHP_EOL; 268 | } 269 | } 270 | 271 | /** 272 | * Closes feed item tag 273 | * 274 | * @access private 275 | * @return void 276 | */ 277 | private function endItem() 278 | { 279 | if ($this->version == RSS2 || $this->version == RSS1) { 280 | echo '' . PHP_EOL; 281 | } else if ($this->version == ATOM) { 282 | echo "" . PHP_EOL; 283 | } 284 | } 285 | 286 | /** 287 | * Closes the open tags at the end of file 288 | * 289 | * @access private 290 | * @return void 291 | */ 292 | private function printTale() 293 | { 294 | if ($this->version == RSS2) { 295 | echo '' . PHP_EOL . ''; 296 | } elseif ($this->version == RSS1) { 297 | echo ''; 298 | } else if ($this->version == ATOM) { 299 | echo ''; 300 | } 301 | 302 | } 303 | // End # public functions ---------------------------------------------- 304 | 305 | // Start # private functions ---------------------------------------------- 306 | 307 | /** 308 | * Create a new FeedItem. 309 | * 310 | * @return object instance of FeedItem class 311 | */ 312 | public function createNewItem() 313 | { 314 | $Item = new FeedItem($this->version); 315 | return $Item; 316 | } 317 | 318 | /** 319 | * Add a FeedItem to the main class 320 | * 321 | * @param $feedItem object instance of FeedItem class 322 | * @return void 323 | */ 324 | public function addItem($feedItem) 325 | { 326 | $this->items[] = $feedItem; 327 | } 328 | 329 | /** 330 | * Set the 'title' channel element 331 | * 332 | * @param $title string value of 'title' channel tag 333 | * @return void 334 | */ 335 | public function setTitle($title) 336 | { 337 | $this->setChannelElement('title', $title); 338 | } 339 | 340 | /** 341 | * Set the 'description' channel element 342 | * 343 | * @access public 344 | * @param srting value of 'description' channel tag 345 | * @return void 346 | */ 347 | public function setDescription($desciption) 348 | { 349 | $this->setChannelElement('description', $desciption); 350 | } 351 | 352 | /** 353 | * Set the 'link' channel element 354 | * 355 | * @access public 356 | * @param srting value of 'link' channel tag 357 | * @return void 358 | */ 359 | public function setLink($link) 360 | { 361 | $this->setChannelElement('link', $link); 362 | } 363 | 364 | /** 365 | * Set the 'image' channel element 366 | * 367 | * @access public 368 | * @param srting title of image 369 | * @param srting link url of the imahe 370 | * @param srting path url of the image 371 | * @return void 372 | */ 373 | public function setImage($title, $link, $url) 374 | { 375 | $this->setChannelElement('image', array('title' => $title, 'link' => $link, 'url' => $url)); 376 | } 377 | 378 | /** 379 | * Set the 'about' channel element. Only for RSS 1.0 380 | * 381 | * @access public 382 | * @param srting value of 'about' channel tag 383 | * @return void 384 | */ 385 | public function setChannelAbout($url) 386 | { 387 | $this->data['ChannelAbout'] = $url; 388 | } 389 | 390 | 391 | // End # private functions ---------------------------------------------- 392 | 393 | } // end of class FeedWriter 394 | 395 | 396 | /** 397 | * FeedItem class - Used as feed element in FeedWriter class 398 | */ 399 | class FeedItem 400 | { 401 | private $elements = array(); //Collection of feed elements 402 | private $version; 403 | 404 | /** 405 | * Constructor 406 | * 407 | * @param $version constant (RSS1/RSS2/ATOM) RSS2 is default. 408 | */ 409 | function __construct($version = RSS2) 410 | { 411 | $this->version = $version; 412 | } 413 | 414 | /** 415 | * Set multiple feed elements from an array. 416 | * Elements which have attributes cannot be added by this method 417 | * 418 | * @access public 419 | * @param array array of elements in 'tagName' => 'tagContent' format. 420 | * @return void 421 | */ 422 | public function addElementArray($elementArray) 423 | { 424 | if (!is_array($elementArray)) return; 425 | foreach ($elementArray as $elementName => $content) { 426 | $this->addElement($elementName, $content); 427 | } 428 | } 429 | 430 | /** 431 | * Add an element to elements array 432 | * 433 | * @param $elementName string The tag name of an element 434 | * @param $content string The content of tag 435 | * @param $attributes array Attributes(if any) in 'attrName' => 'attrValue' format 436 | * @return void 437 | */ 438 | public function addElement($elementName, $content, $attributes = null) 439 | { 440 | if ($elementName == 'category') { 441 | if (!array_key_exists('category', $this->elements)) { 442 | $this->elements[$elementName]['content'] = array($content); 443 | } else { 444 | array_push($this->elements[$elementName]['content'], $content); 445 | } 446 | } else { 447 | $this->elements[$elementName]['content'] = $content; 448 | } 449 | $this->elements[$elementName]['name'] = $elementName; 450 | $this->elements[$elementName]['attributes'] = $attributes; 451 | } 452 | 453 | /** 454 | * Return the collection of elements in this feed item 455 | * 456 | * @access public 457 | * @return array 458 | */ 459 | public function getElements() 460 | { 461 | return $this->elements; 462 | } 463 | 464 | // Wrapper functions ------------------------------------------------------ 465 | 466 | /** 467 | * Set the 'dscription' element of feed item 468 | * 469 | * @access public 470 | * @param string The content of 'description' element 471 | * @return void 472 | */ 473 | public function setDescription($description) 474 | { 475 | $tag = ($this->version == ATOM) ? 'summary' : 'description'; 476 | $this->addElement($tag, $description); 477 | } 478 | 479 | /** 480 | * @desc Set the 'title' element of feed item 481 | * @access public 482 | * @param string The content of 'title' element 483 | * @return void 484 | */ 485 | public function setTitle($title) 486 | { 487 | $this->addElement('title', $title); 488 | } 489 | 490 | /** 491 | * Set the 'date' element of feed item 492 | * 493 | * @access public 494 | * @param string The content of 'date' element 495 | * @return void 496 | */ 497 | public function setDate($date) 498 | { 499 | if (!is_numeric($date)) { 500 | $date = strtotime($date); 501 | } 502 | 503 | if ($this->version == ATOM) { 504 | $tag = 'updated'; 505 | $value = date(DATE_ATOM, $date); 506 | } elseif ($this->version == RSS2) { 507 | $tag = 'pubDate'; 508 | $value = date(DATE_RSS, $date); 509 | } else { 510 | $tag = 'dc:date'; 511 | $value = date("Y-m-d", $date); 512 | } 513 | 514 | $this->addElement($tag, $value); 515 | } 516 | 517 | /** 518 | * Set the 'link' element of feed item 519 | * 520 | * @access public 521 | * @param string The content of 'link' element 522 | * @return void 523 | */ 524 | public function setLink($link) 525 | { 526 | if ($this->version == RSS2 || $this->version == RSS1) { 527 | $this->addElement('link', $link); 528 | } else { 529 | $this->addElement('link', '', array('href' => $link)); 530 | $this->addElement('id', FeedWriter::uuid($link, 'urn:uuid:')); 531 | } 532 | 533 | } 534 | 535 | /** 536 | * Set the 'encloser' element of feed item 537 | * For RSS 2.0 only 538 | * 539 | * @access public 540 | * @param string The url attribute of encloser tag 541 | * @param string The length attribute of encloser tag 542 | * @param string The type attribute of encloser tag 543 | * @return void 544 | */ 545 | public function setEncloser($url, $length, $type) 546 | { 547 | $attributes = array('url' => $url, 'length' => $length, 'type' => $type); 548 | $this->addElement('enclosure', '', $attributes); 549 | } 550 | 551 | } // end of class FeedItem 552 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [English](#eng) | [Russian](#rus) 2 | 3 | --- 4 | 5 | # Generating RSS Feed for opened or closed wall of user or community (group, public page or event page) on vk.com 6 | 7 | ## Features 8 | * Generating RSS feed of opened wall: data extraction from different 9 | post parts (attachments included) and automatic title generation 10 | of RSS items. 11 | * Also generating RSS feed of closed wall if there's access token 12 | with offline permissions that's created by user who has access 13 | to the closed wall. [See more here](#eng-user-access-token) 14 | about user access token creating. 15 | * Generating RSS feed for different opened walls based on 16 | [global search](#eng-global-search) results. 17 | * Generating RSS [news feed](#eng-newsfeed) of access token's owner. 18 | * Feeding [arbitrary number](#eng-count) of posts. 19 | * Posts filtering [by author](#eng-owner-only): all posts, posts by community/profile owner 20 | only or all posts except posts by community/profile owner. 21 | * Posts filtering by [signature presence](#eng-sign). 22 | * Posts filtering by [regular expression](#eng-include) (PCRE notation) 23 | matching and/or mismatching. 24 | * Optionally [ad posts skipping](#eng-ads) [disabled by default]. 25 | * Extracting RSS categories from the post hash tags. 26 | * Optionally [HTML formatting](#eng-html) of RSS item description: 27 | links, images, line breaks [enabled by default]. 28 | * HTTPS, SOCKS4, SOCKS4A or SOCKS5 [proxy usage](#eng-proxy) is available. 29 | * Each feed item has author name (post signer/publisher or source post 30 | signer/publisher if wall post is the repost). 31 | * Customizable [repost delimiter](#eng-repost-delimiter) with substitutions. 32 | * Optionally [video embedding](#eng-videos) as iframe [disabled by default] in the default HTML mode. 33 | * Optionally [VK Donut posts including](#eng-donut) [disabled by default]. 34 | 35 | 36 | ## Requirements 37 | * PHP>=5.3 (5.4.X, 5.5.X, 5.6.X, 7.X, 8.X are included) 38 | with installed `mbstring`, `json`, `pcre`, `openssl` bundled extensions. 39 | * Script prefers the built-in tools for the requests. 40 | If `allow_url_fopen` parameter is disabled in the PHP configuration 41 | file or interpreter parameters and `cURL` PHP extension is installed 42 | then script uses `cURL` for the requests. 43 | * If you want to use proxy server then 44 | * for HTTPS proxy: either `cURL`>=7.10 extension must be installed 45 | **or** `allow_url_fopen` parameter must be enabled in the PHP configuration 46 | file or interpreter parameters; 47 | * for SOCKS5 proxy: `cURL`>=7.10 extension must be installed; 48 | * for SOCKS4 proxy: PHP>=5.3 with `cURL`>=7.10 extension is required; 49 | * for SOCKS4A proxy: PHP>=5.5.23 or PHP>=5.6.7 (7.X included) 50 | with `cURL`>=7.18 extension is required. 51 | 52 | If script returns page with non-200 HTTP status then some problem was occurred: 53 | detailed problem information is described in the HTTP status phrase, 54 | in the script output and in the server/interpreter logfile. 55 | 56 | ## Parameters 57 | Main `index.php` script accepts the below GET-parameters. 58 | 59 | [`id`](#eng-id) and [`access_token`](#eng-access-token) 60 | **OR** [`global_search`](#eng-global-search) and [`access_token`](#eng-access-token) 61 | **OR** [`news_feed`](#eng-newsfeed) and [`access_token`](#eng-access-token) 62 | parameters are required, another parameters are optional. 63 | 64 | [`id`](#eng-id), [`global_search`](#eng-global-search) and [`news_feed`](#eng-newsfeed) parameters **cannot** be used together. 65 | 66 | * [conditionally required] 67 | `id` is short name, ID number (community ID is started with `-` sign) 68 | or full identifier (like idXXXX, clubXXXX, publicXXXX, eventXXXX) of profile or community. 69 | Only its single wall is processed. 70 | Examples of a valid values: 71 | * `123456`, `id123456` — both of these values identify the user profile with ID 123456, 72 | * `-123456`, `club123456` — both of these values identify the group with ID 123456, 73 | * `-123456`, `public123456` — both of these values identify the public page with ID 123456, 74 | * `-123456`, `event123456` — both of these values identify the event with ID 123456, 75 | * `apiclub` — value identifies the user profile or community with this short name. 76 | 77 | Deprecated `domain` and `owner_id` parameters are allowed and they're identical to `id`. 78 | * [conditionally required] 79 | `global_search` is an arbitrary text search query to lookup on all **opened** walls. 80 | It uses internal VK algorithms to search posts that're published by wall's **owner**. 81 | Search results are the same as on [this search page](https://vk.com/search?c[section]=statuses). 82 | 83 | * [conditionally required] 84 | `news_type` takes one of the values either `recent` (recent news) or `recommended` (VK recommended news). 85 | It generates an RSS news feed of the access token's owner that's shown on [this news page](https://vk.com/feed). 86 | 87 | News feed contains a **walls posts only**, the rest of news are ignored 88 | (such as new friends of friends, new photos in friends' profiles and so on). 89 | 90 | This parameter **requires** a user' access token with `wall` and `friends` permissions. 91 | 92 | You can filter `recent` news by user, community or custom news list using [`news_sources`](#eng-news-sources) parameter. 93 | 94 | * [required] `access_token` is 95 | * either service token that's specified in the app settings 96 | (you can create your own standalone application 97 | [here](https://vk.com/editapp?act=create), app can be off) 98 | 99 | Service token allows to fetch only opened for everyone walls. 100 | * or [user access token with `offline` and optionally `video` permissions](#eng-user-access-token) 101 | 102 | If you uses [`id`](#eng-id) parameter then user access token allows 103 | to fetch both opened and closed walls that are opened for this user. 104 | 105 | Warning: If user terminates all sessions in the security settings of profile 106 | then him access token becomes invalid; in that case, user must create new access token. 107 | 108 | If you uses [`global_search`](#eng-global-search) then service and user access tokens give equivalent results, 109 | i.e. only opened walls is processed. 110 | 111 | * `news_sources` is a comma-separated list of `recent` news sources. 112 | 113 | This filter works with [`news_type=recent`](#eng-newsfeed) parameter only. 114 | 115 | Each value has one of the following format: 116 | * `friends` outputs news posts of each friend; 117 | * `groups` outputs news posts of each community from the current user's subscription list; 118 | * `pages` outputs news posts of each public page from the current user's subscription list; 119 | * `following` outputs news posts of each following user of the current user; 120 | * `list` outputs news posts from the personal source' list (created by the current user); 121 | * `` or `u` outputs news posts by user ``; 122 | * `-` or `g` outputs news posts by community ``. 123 | 124 | **For example**, `news_sources=u1,-2,following,list3` outputs recent posts from news feed 125 | that's published by user `id1`, community `club2`, following users or posts from the personal news list with ID `3`. 126 | 127 | * `count` is a number of processing posts 128 | starting with the latest published post. 129 | It's arbitrary amount including more than 100. 130 | 131 | *Default value*: 20. 132 | 133 | If [`owner_only`](#eng-owner-only), [`non_owner_only`](#eng-non-owner-only), 134 | [`include`](#eng-include), [`exclude`](#eng-exclude) or [`skip_ads`](#eng-ads) 135 | parameters are passed then amount of posts in the result RSS feed can be 136 | less than `count` because some post can be skipped by these parameters. 137 | 138 | If [`donut`](#eng-donut) is passed then amount of posts in the result RSS feed can be 139 | at most `2*count` (`count` VK Donut posts + `count` regular posts). 140 | 141 | If [`id`](#eng-id) is passed then `count` is unlimited, but API requests number can be no more than 142 | **5000 requests per day** and each request can fetch no more than 100 posts. 143 | 144 | If [`global_search`](#eng-global-search) is passed then maximum value of `count` is **1000**, 145 | API requests number can be no more than **1000 requests per day**, 146 | and each request can fetch no more than 200 posts. 147 | 148 | Delay between requests is equal to 1 sec in order to satisfy VK API limits 149 | (no more than 3 requests per second). 150 | * `repost_delimiter` is a string that's placed 151 | between parent and child posts; in other words, it's a header of a child post 152 | in the repost. 153 | 154 | *Default value* is `

` if HTML formatting is enabled (default behaviour), 155 | otherwise `______________________` ([`disable_html`](#eng-html) parameter). 156 | 157 | This parameter can contain the next special strings that will be substituted in the RSS feed: 158 | * `{author}` that's replaced with first and last names of child post' author 159 | in the nominative case if author is a user, 160 | otherwise it's replaced with community name in the nominative that's published child post. 161 | * `{author_ins}` that's replaced with first and last names 162 | of child post' author in the instrumental case if author is a user, 163 | otherwise it's replaced with community name in the nominative that's published child post 164 | * `{author_gen}` that's replaced with first and last names 165 | of child post' author in the genitive case if author is a user, 166 | otherwise it's replaced with community name in the nominative that's published child post 167 | 168 | Author is child post' signer if it exists, otherwise it's child post' publisher. 169 | 170 | E.g., parameter value `
Written by {author}` is replaced with: 171 | * `
Written by John Smith` if author is user and publisher of child post, 172 | * `
Written by Fun Club` if author is community, 173 | * `
Written by John Smith in Fun Club` if author is user and signer of child post. 174 | 175 | Additionally substitutions adds links to user/community pages 176 | that're represented as either HTML hyperlinks on author name or plain text in the brackets 177 | (if [`disable_html`](#eng-html) is enabled). 178 | * `donut` passing (including absent value) indicates that RSS 179 | contains donut posts (VK Donut subscription) at the beginning, followed by regular posts. 180 | Total amount of posts in the RSS feed can be at most [`2*count`](#eng-count) posts 181 | (`count` donut posts + `count` regular posts) if they exist. 182 | 183 | If an **API Error 15** occurs by this parameter usage, it means that: 184 | * either user (whose [`access_token`](#eng-access-token) is used) has not subscribed to VK Donut 185 | of community with [`id`](#eng-id), 186 | * or community has not enabled VK Donut feature, 187 | * or [`id`](#eng-id) belongs to some user instead of community. 188 | * `include` is case-insensitive regular expression (PCRE notation) 189 | that must match the post text. Another posts will be skipped. 190 | 191 | Symbol `/` **is not** required at the start and at the end of regular expression. 192 | * `exclude` is case-insensitive regular expression (PCRE notation) 193 | that must **not** match the post text. Another posts will be skipped. 194 | 195 | Symbol `/` **is not** required at the start and at the end of regular expression. 196 | * `disable_html` passing (including absent value) indicates 197 | that RSS item descriptions must be without HTML formatting. 198 | 199 | *By default* HTML formatting is applied for links and images. 200 | * `disable_comments_amount` passing (including absent value) indicates that RSS item 201 | must be without amount of comments (``). 202 | 203 | *By default* feed item contains number of comments. 204 | * `owner_only` passing (including absent value) indicates that RSS must 205 | contain only posts that's 206 | * published by community in the case of community wall; 207 | 208 | If [`allow_signed`](#eng-sign) parameter with `false` value is also passed 209 | then posts with signature (that's published by community) will be skipped. 210 | * published by profile owner in the case of user wall. 211 | 212 | *By default* [absent parameter] RSS feed contains all posts that passes another filters. 213 | * `non_owner_only` or `not_owner_only` passing (including absent value) 214 | indicates that RSS must contain only posts that's 215 | * not published by community in the case of community wall, i.e. published by users. 216 | 217 | If [`allow_signed`](#eng-sign) parameter with `true` or absent value is also passed 218 | then posts with signature (that's published by community) 219 | will be included to the RSS feed. 220 | * not published by profile owner in the case of user wall, i.e. published by another users. 221 | 222 | *By default* [absent parameter] RSS feed contains all posts that passes another filters. 223 | * `allow_signed` allows or disallows posts (that's published by community) 224 | with signature when [`owner_only`](#eng-owner-only) or [`non_owner_only`/`not_owner_only`](#eng-non-owner-only) 225 | parameter is passed. 226 | 227 | *By default* [absent parameter] RSS feed contains all posts that passes another filters. 228 | 229 | Allowed values: [absent value] (same as `true`), `true`, `false`, 230 | `0` (same as `false`), `1` (same as `true`). Another values are interpreted as `true`. 231 | * If [`owner_only`](#eng-owner-only) is passed then `allow_signed` with `false` value doesn't include 232 | posts with signature to the RSS feed. 233 | * If [`non_owner_only` or `not_owner_only`](#eng-non-owner-only) is passed 234 | then `allow_signed` with `true` value includes posts 235 | with signature to the RSS feed. 236 | * `skip_ads` passing indicates that all marked as ad posts will be skipped. 237 | 238 | *By default* [absent parameter] RSS feed contains all posts that passes another filters. 239 | 240 | **Note**: Some wall posts that're marked as ad on the website, 241 | VK API doesn't mark as ad, therefore some ad posts can be in the RSS feed. 242 | * `allow_embedded_video` allows or disallows 243 | video player embedding (as iframe) into the posts' description. 244 | 245 | *By default* [absent parameter] it is disabled. 246 | 247 | When it's enabled, script tries to get video player link 248 | but if the [`access_token`](#eng-user-access-token) does not have the `video` permission 249 | then this parameter turn into `false` forcibly. 250 | 251 | If script can get video player link then videos are playable in the HTML mode, 252 | otherwise videos are displayed as either clickable image preview (HTML is enabled) or text (HTML is disabled). 253 | * `proxy` is proxy server address. Allowed value formats: 254 | * `address`, 255 | * `address:port`, 256 | * `type://address`, 257 | * `type://address:port`, 258 | * `login:password@address`, 259 | * `login:password@address:port`, 260 | * `type://login:password@address`, 261 | * `type://login:password@address:port`, 262 | 263 | where `address` is proxy domain or IP-address, `port` is proxy port, 264 | `type` is proxy type (HTTPS, SOCKS4, SOCKS4A, SOCKS5), 265 | `login` and `password` are login and password for proxy access if it's necessary. 266 | 267 | Proxy type, login and password can be passed through another parameters: 268 | `proxy_type`, `proxy_login` and `proxy_password` respectively. 269 | 270 | ## How To Get Permanent User Access Token 271 | [This authorization flow](https://vk.com/dev/authcode_flow_user) is 272 | preferred getting user access token for the server side access to the walls. 273 | 274 | 1. Create your own standalone application [here](https://vk.com/editapp?act=create). 275 | Created app can be off after token generation because it does not matter for the API requests. 276 | 277 | **IMPORTANT NOTE**: currently, [newly created apps](https://id.vk.com/business/go) 278 | require passport' data or organisation' data in the developer' dashboard 279 | in order to generate access tokens with required permissions. 280 | 281 | However, [old apps](https://vk.com/apps?act=manage) still provide the ability 282 | to generate access tokens with required permissions. 283 | 284 | 2. Authorize necessary account on vk.com and go to the next URL 285 | 286 | `https://oauth.vk.com/authorize?client_id=APP_ID&scope=offline,video,wall,friends&redirect_uri=https%3A%2F%2Foauth.vk.com%2Fblank.html&display=page&response_type=token&revoke=1` 287 | 288 | where replace `APP_ID` with application ID that's specified in the app settings. 289 | 290 | The listed permissions in the `scope` parameter are as the follows: 291 | * `offline` is required to get endless access token; 292 | * `video` is only required for the [`allow_embedded_video`](#eng-videos) option; 293 | * `wall` and `friends` are only required for the [`news_type`](#eng-newsfeed) option. 294 | 295 | Therefore, if you do not want to use some features then the relevant permissions can be omitted. 296 | 297 | 3. Confirm permissions. The result URL in the address bar 298 | contains sought-for access token after the `access_token=` string. 299 | 300 | Bonus: created app keeps API calls statistics, so you can see it. 301 | 302 | **Warning**: If user terminates all sessions in him security settings 303 | then all him access tokens becomes invalid; in that case, user must create 304 | new access token by repeating steps 2-4. 305 | 306 | 307 | ## Usage Examples 308 | ```php 309 | index.php?id=apiclub&access_token=XXXXXXXXX 310 | index.php?id=-1&access_token=XXXXXXXXX 311 | index.php?id=id1&access_token=XXXXXXXXX 312 | index.php?id=club1&access_token=XXXXXXXXX 313 | index.php?id=club1&disable_html&access_token=XXXXXXXXX # no HTML formatting in RSS item descriptions 314 | index.php?id=apiclub&count=100&include=newsfeed&access_token=XXXXXXXXX # feed contains only posts with substring 'newsfeed' 315 | index.php?id=apiclub&count=100&exclude=newsfeed&access_token=XXXXXXXXX # feed contains only posts without substring 'newsfeed' 316 | index.php?id=apiclub&proxy=localhost:8080&access_token=XXXXXXXXX 317 | index.php?id=apiclub&proxy=localhost:8080&proxy_type=https&access_token=XXXXXXXXX 318 | index.php?id=apiclub&proxy=https%3A%2F%2Flocalhost:8080&access_token=XXXXXXXXX 319 | index.php?id=club1&owner_only&access_token=XXXXXXXXX # feed contains only posts by community 320 | index.php?id=club1&owner_only&allow_signed=false&access_token=XXXXXXXXX # feed contains only posts by community 321 | # that's without signature 322 | index.php?id=club1&non_owner_only&access_token=XXXXXXXXX # feed contains only posts by users 323 | index.php?id=club1&non_owner_only&allow_signed&access_token=XXXXXXXXX # feed contains only posts by users 324 | # and community posts with signature 325 | index.php?id=club1&allow_embedded_video&access_token=XXXXXXXXX # embed playable videos into RSS items' description 326 | index.php?id=-1&count=30&repost_delimiter=

Written by {author}:&access_token=XXXXXXXXX 327 | index.php?id=pitertransport&donut&access_token=XXXXXXXXX # RSs feed contains VK Donut posts and regular posts 328 | index.php?news_type=recent&count=25&access_token=XXXXXXXXX # 25 recent posts of the news feed 329 | index.php?news_type=recent&news_sources=list1&count=20&access_token=XXXXXXXXX # 20 recent posts of the custom news feed with ID 1 330 | index.php?news_type=recommended&count=30&access_token=XXXXXXXXX # 30 VK recommended posts of the news feed 331 | index.php?global_search=query&count=300&access_token=XXXXXXXXX # search posts that contains 'query' 332 | index.php?id=-1&count=100&include=(new|wall|\d+)&access_token=XXXXXXXXX 333 | ``` 334 | **Note**: one parameter contains special characters in the two last examples, 335 | so URL-encoding can be required for the direct call, e.g.: 336 | ```index.php?id=-1&count=100&include=(new%7Cwall%7C%5Cd%2B)&access_token=XXXXXXXXX``` 337 | 338 | 339 | ## Troubleshooting 340 | * If you get error: 341 | > date(): It is not safe to rely on the system's timezone settings. 342 | You are *required* to use the date.timezone setting or 343 | the date_default_timezone_set() function. In case you used any 344 | of those methods and you are still getting this warning, 345 | you most likely misspelled the timezone identifier. 346 | We selected the timezone 'UTC' for now, but please set date.timezone 347 | to select your timezone. 348 | 349 | then set timezone in php configuration (`date.timezone` parameter) or 350 | add line like `date_default_timezone_set('UTC');` to the start 351 | of the `index.php` script (before `require_once` statement). 352 | * If your RSS aggregator marks post as new/updated when number of its comments is changed 353 | then you can disable comments counter for each RSS item 354 | using GET-parameter [`disable_comments_amount`](#eng-comments-counter): 355 | 356 | ```index.php?id=-1&disable_comments_amount``` 357 | or 358 | ```index.php?id=-1&disable_comments_amount=1``` 359 | * If an **API Error 15** occurs by [`donut`](#eng-donut) parameter usage, it means that: 360 | * either user (whose [`access_token`](#eng-access-token) is used) has not subscribed to VK Donut 361 | of community with [`id`](#eng-id), 362 | * or community has not enabled VK Donut feature, 363 | * or [`id`](#eng-id) belongs to some user instead of community. 364 | 365 | --- 366 | 367 | # Генерация RSS-ленты открытой или закрытой стены пользователя или сообщества (группы, публичной страницы или мероприятия) во Вконтакте. 368 | 369 | 370 | ## Возможности: 371 | * Получение RSS-ленты открытой стены: извлечение описания из разных частей 372 | (включая вложения) и построение заголовков на основе описания. 373 | * Также получение RSS-ленты закрытой стены при наличии токена с правами оффлайн-доступа, 374 | привязанного к профилю, которому открыт доступ к такой стене. 375 | [Ниже описан один из способов получения токена](#rus-user-access-token). 376 | * Получение RSS-ленты, содержащей записи с различных открытых стен, 377 | которые соответствуют [глобальному поисковому запросу](#rus-global-search). 378 | * Получение RSS-ленты [новостей](#rus-newsfeed) владельца токена. 379 | * Получение [произвольного количества](#rus-count) записей со стены. 380 | * Получение записей, [опубликованных](#rus-owner-only) от кого угодно, от имени 381 | сообщества/владельца страницы или ото всех, кроме сообщества/владельца страницы. 382 | * Фильтрация записей по наличию или отсутствию [подписи](#rus-sign). 383 | * Фильтрация записей по соответствию и/или несоответствию 384 | [регулярному выражению](#rus-include) в стиле PCRE. 385 | * При желании исключение записей в сообществе, помеченных как [реклама](#rus-ads) 386 | [по умолчанию отключено]. 387 | * Извлечение хеш-тегов в качестве RSS-категорий. 388 | * При желании [HTML-форматирование](#rus-html) всех видов ссылок, изображений, 389 | переносов строк [по умолчанию включено]. 390 | * Допустимо использование HTTPS, SOCKS4, SOCKS4A или SOCKS5 391 | [прокси-сервера](#rus-proxy) для запросов. 392 | * У каждой записи в ленте указан автор (либо тот, кто подписан или опубликовал запись, 393 | либо тот, кто подписан или опубликовал исходную запись, если конечная запись является репостом исходной). 394 | * Возможность задать свой [собственный разделитель](#rus-repost-delimiter) с подстановками 395 | между родительским и дочерним записями (репосты). 396 | * При желании [встраивание видеозаписей](#rus-videos) в описание RSS записей с помощью iframe (по умолчанию отключено) при по умолчанию включенном HTML режиме. 397 | * При желании можно [включить записи для донов](#rus-donut) в RSS-ленту (по умолчанию отключено). 398 | 399 | 400 | ## Требования 401 | * PHP>=5.3 (в т.ч. 5.4.X, 5.5.X, 5.6.X, 7.X, 8.X) с установленными 402 | по умолчанию поставляемыми расширениями `mbstring`, `json`, `pcre`, `openssl`. 403 | * Скрипт предпочитает использовать встроенные в PHP возможности по отправке запросов. 404 | Если у PHP отключена встроенная возможность загрузки файлов по URL 405 | (отключен параметр `allow_url_fopen` в конфигурации или параметрах интерпретатора), 406 | но при этом у PHP установлено расширение `cURL`, 407 | то именно оно будет использоваться для загрузки данных. 408 | * Если необходимо использовать прокси-сервер, то в случае 409 | * HTTPS-прокси — либо необходимо расширение `cURL`>=7.10, **либо** 410 | в конфигурационном файле или параметрах интерпретатора PHP 411 | должен быть включён параметр `allow_url_fopen`, 412 | * SOCKS5-прокси — необходимо расширение `cURL`>=7.10, 413 | * SOCKS4-прокси — необходим PHP>=5.3 с расширением `cURL`>=7.10, 414 | * SOCKS4A-прокси — необходим PHP>=5.5.23 или PHP>=5.6.7 (включая 7.X) с расширением `cURL`>=7.18. 415 | 416 | В случае каких-либо проблем вместо RSS-ленты выдается страница с HTTP-статусом, 417 | отличным от 200, и с описанием проблемы в HTTP-заголовке и теле страницы, 418 | а также создаётся запись в журнале ошибок сервера или интерпретатора. 419 | 420 | 421 | ## Параметры 422 | Основной скрипт `index.php` принимает следующие GET-параметры. 423 | 424 | Пара параметров [`id`](#rus-id) и [`access_token`](#rus-access-token) 425 | **ИЛИ** [`global_search`](#rus-global-search) и [`access_token`](#rus-access-token) 426 | **ИЛИ** [`news_type`](#rus-newsfeed) и [`access_token`](#rus-access-token) 427 | обязательна, остальные параметры необязательны. 428 | 429 | Одновременно можно использовать только один из параметров 430 | [`id`](#rus-id), [`global_search`](#rus-global-search) или [`news_type`](#rus-newsfeed). 431 | 432 | * [условно обязательный] `id` — короткое название, ID-номер (в случае сообщества ID начинается со знака `-`) 433 | или полный идентификатор человека/сообщества (в виде idXXXX, clubXXXX, publicXXXX, eventXXXX), 434 | для стены которого будет строиться RSS-лента. 435 | Примеры допустимых значений параметра `id`: 436 | * `123456`, `id123456` — оба значения указывают на одну и ту же страницу пользователя с ID 123456, 437 | * `-123456`, `club123456` — оба значения указывают на одну и ту же группу с ID 123456, 438 | * `-123456`, `public123456` — оба значения указывают на одну и ту же публичную страницу с ID 123456, 439 | * `-123456`, `event123456` — оба значения указывают на одну и ту же страницу мероприятия с ID 123456, 440 | * `apiclub` — значение указывает на пользователя или сообщество с данным коротким названием. 441 | 442 | Ради обратной совместимости допускается вместо `id` использовать `domain` или `owner_id`. 443 | 444 | * [условно обязательный] `global_search` — 445 | произвольный текстовый глобальный поисковый запрос, по которому 446 | с помощью внутренних алгоритмов ВКонтакте ищутся 447 | записи со всех открытых стен, опубликованные владельцем профиля пользователя 448 | или от имени сообщества. Результаты поиска аналогичны результатам 449 | [на этой поисковой странице](https://vk.com/search?c%5Bsection%5D=statuses). 450 | 451 | * [условно обязательный] `news_type` — 452 | тип новостной ленты владельца ключа доступа: значение 453 | либо `recent` (последние новости), либо `recommended` (рекомендуемые новости) 454 | — новости, которые отображаются [на странице новостей](https://vk.com/feed) 455 | и только те, что являются **записями** на чьей-либо стене (все прочие новости, 456 | такие как новые друзья друзей, новые фотографии в профилях друзей и т.п., будут проигнорированы). 457 | 458 | Для использования этого параметра у ключа доступа пользователя обязательно наличие прав `wall` и `friends`. 459 | 460 | С помощью параметра [`news_sources`](#rus-news-sources) можно получить последние новости (`recent`) 461 | от отдельных людей, сообществ или из собственных списков. 462 | 463 | * [обязательный] `access_token` — 464 | * Либо сервисный ключ доступа, который указан в настройках приложения 465 | (создать собственное standalone-приложение можно 466 | [по этой ссылке](https://vk.com/editapp?act=create), само приложение может быть выключено). 467 | 468 | Сервисный ключ доступа дает возможность получать записи только с открытых для всех стен. 469 | * Либо [ключ доступа пользователя с правами оффлайн-доступа и опционально видео-доступа](#rus-user-access-token). 470 | 471 | При использовании параметра [`id`](#rus-id) ключ доступа пользователя позволяет 472 | получать записи как с открытых, так и закрытых стен 473 | (но открытых для пользователя, который создал токен). 474 | 475 | Предупреждение: если в настройках безопасности пользователя будут завершены все сессии, 476 | то ключ доступа пользователя станет невалидным — нужно сформировать ключ заново. 477 | 478 | Если используется параметр [`global_search`](#rus-global-search), тогда генерируемые RSS-ленты 479 | при использовании сервисного ключа доступа и при использовании ключа 480 | доступа пользователя одинаковы, 481 | т.е. в любом случае все записи будут лишь с открытых стен. 482 | 483 | * `news_sources` — список пользователей, сообществ или идентификаторов списков новостей. 484 | При использовании этого параметра будут выводиться новости только от перечисленных источников. 485 | 486 | Использование этого фильтра допустимо только в комбинации с параметром [`news_type=recent`](#rus-newsfeed). 487 | 488 | Список представляет собой перечень одного или нескольких значений, разделенных запятой, 489 | каждое из которых имеет один из следующих форматов: 490 | * `friends` — новостные записи ото всех друзей; 491 | * `groups` — новостные записи всех сообществ, на которые подписан текущий пользователь; 492 | * `pages` — новостные записи всех публичных страниц, на которые подписан текущий пользователь; 493 | * `following` — новостные записи пользователей, на которых подписан текущий пользователь; 494 | * `list` — собственный список новостей с идентификатором ``, созданный пользователем; 495 | * `` или `u` — новостные записи от пользователя с идентификатором ``; 496 | * `-` или `g` — новостные записи от сообщества с идентификатором ``. 497 | 498 | **Пример:** `news_sources=u1,-2,following,list3` — выводит только те записи из новостной ленты, которые под авторством 499 | либо пользователя `id1`, либо сообщества `club2`, либо пользователей, на которых подписан текущий пользователь, 500 | либо новости из персонального списка номер `3`, созданного текущим пользователем в своем профиле. 501 | 502 | * `count` — количество обрабатываемых записей, 503 | начиная с последней опубликованной 504 | (произвольное количество, включая более 100, *по умолчанию 20*). 505 | 506 | Если дополнительно установлены параметры [`owner_only`](#rus-owner-only), [`non_owner_only`](#rus-non-owner-only), 507 | [`include`](#rus-include), [`exclude`](#rus-exclude) или [`skip_ads`](#rus-ads), то количество выводимых в RSS-ленте 508 | записей может быть меньше значения `count` за счет исключения записей, 509 | которые отсеиваются этими параметрами. 510 | 511 | Если передан параметр [`donut`](#rus-donut), то RSS-лента может содержать до `2*count` записей 512 | (максимум `count` записей для донов плюс максимум `count` обычных записей). 513 | 514 | Если передан параметр [`id`](#rus-id), то значение `count` неограниченно, но VK API 515 | позволяет делать не более **5000 запросов в сутки**, а каждый запрос может 516 | получить не более 100 записей. 517 | 518 | Если передан параметр [`global_search`](#rus-global-search), то значение `count` не может быть 519 | больше **1000**, при этом VK API позволяет делать не более **1000 запросов в сутки**, 520 | каждый из которых может извлечь не более 200 записей. 521 | 522 | Между запросами задержка минимум в 1 секунду, чтобы 523 | не превышать ограничения VK API (не более 3 запросов в секунду). 524 | 525 | * `repost_delimiter` — разделитель 526 | между родительской и дочерней записью (когда профиль/сообщество [«родитель»] 527 | поделилось записью от другого профиля/сообщества [«ребенок»]), 528 | иными словами, заголовок дочерней записи. 529 | 530 | *По умолчанию* разделителем служит `

` в случае по умолчанию 531 | включенного HTML-форматирования и `______________________` 532 | в случае отключенного HTML-форматирования (параметр [`disable_html`](#rus-html)): 533 | 534 | В качестве значения параметра может быть передана любая строка. 535 | Допустимо использование специальных подстановок: 536 | * `{author}` — в RSS ленте заменяется на автора дочерней записи в именительном падеже. 537 | * `{author_gen}` — заменяется на автора дочерней записи в родительном падеже в случае, 538 | если этот автор является пользователем, а если автор — сообщество, 539 | то заменяется на название сообщества без морфологических изменений. 540 | * `{author_ins}` — заменяется на автора дочерней записи в творительном падеже в случае, 541 | если этот автор является пользователем, а если автор — сообщество, 542 | то заменяется на название сообщества без морфологических изменений. 543 | 544 | Под автором записи понимается в первую очередь подписанный автор, 545 | а если такового нет, то публикатор записи. 546 | 547 | Примеры значений параметра: 548 | * `{author} пишет:` — в случае автора-пользователя подставится, 549 | например, `Иван Иванов пишет:`, а в случае автора-сообщества, например, 550 | `ВКонтакте API пишет:` 551 | * `
Опубликовано {author_ins}:` — в случае автора-пользователя подставится, 552 | например, `Опубликовано Иваном Ивановым:`, а в случае автора-сообщества, например, 553 | `Опубликовано ВКонтакте API:` 554 | * `Запись {author_gen}:` — в случае автора-пользователя подставится, 555 | например, `Запись Ивана Иванова:`, а в случае автора-сообщества, например, 556 | `Запись ВКонтакте API:` 557 | * `
Написано {author_ins}:` — в случае автора-пользователя подставится, 558 | например, `
Написано Иваном Ивановым:`, а в случае автора-сообщества, например, 559 | `
Написано ВКонтакте API:`. Если же запись опубликована в сообществе, но при этом 560 | подписана автором, то подстановка станет наподобие такой: 561 | `
Написано Иваном Ивановым в сообществе ВКонтакте API:` 562 | (аналогично будет в предыдущих примерах) 563 | 564 | В указанных примерах в результатах подстановки еще подставляются либо HTML-форматированные 565 | ссылки на пользователя/сообщество, либо эти же же ссылки в виде простого текста 566 | в случае отключенного HTML-форматирования (параметр [`disable_html`](#rus-html)). 567 | * `donut` — если передан (можно без значения), 568 | то в RSS-ленту будут добавлены записи для донов (по подключенной подписке VK Donut). 569 | При включении в RSS-ленте в первую очередь выводятся записи для донов, а после них обычные записи. 570 | Суммарное количество записей в RSS-ленте может достигать [`2*count`](#rus-count) 571 | (`count` записей для донов + `count` обычных записей), 572 | если столько записей существует и они не фильтруются другими параметрами скрипта. 573 | 574 | Если при использовании этого параметра в результате появляется ошибка **API Error 15**, 575 | то это значит, что: 576 | * либо у пользователя, чей ключ доступа использован, не оформлена подписка на VK Donut сообщества 577 | (стоит удостовериться, что в качестве [`access_token`](#rus-access-token) используется именно ключ доступа пользователя, 578 | у которого есть подписка VK Donut на сообщество с верным [`id`](#rus-id)); 579 | * либо у сообщества отключен VK Donut; 580 | * либо RSS-лента генерируется для стены пользователя, а не сообщества. 581 | * `include` — регистронезависимое регулярное 582 | выражение в стиле PCRE, которое должно соответствовать тексту записи. 583 | Остальные записи будут пропущены. 584 | 585 | В начале и в конце выражения символ `/` **не** нужен. 586 | * `exclude` — регистронезависимое регулярное выражение в стиле PCRE, 587 | которое **не** должно соответствовать тексту записи. 588 | Остальные записи будут пропущены. 589 | 590 | В начале и в конце выражения символ `/` **не** нужен. 591 | * `disable_html` — если передан (можно без значения), 592 | то описание каждой записи не будет содержать никаких HTML тегов. 593 | 594 | *По умолчанию* (отсутствие `disable_html`) описание может включать 595 | HTML-теги для создания гиперссылок и вставки изображений. 596 | * `disable_comments_amount` — если передан (можно без значения), 597 | то в RSS-ленте не будет счетчика комментариев у каждой записи (``). 598 | 599 | *По умолчанию* у каждой записи указано текущее количество комментариев. 600 | * `owner_only` — если передан (можно без значения), 601 | то в RSS-ленту выводятся лишь те записи, которые 602 | * в случае стены сообщества опубликованы от имени сообщества; 603 | 604 | если в этом случае дополнительно передан параметр [`allow_signed=false`](#rus-sign), 605 | то не будут выводиться подписанные записи, опубликованные от имени сообщества. 606 | * в случае стены пользователя опубликованы самим этим пользователем. 607 | 608 | *По умолчанию* (отсутствие параметра) выводятся записи ото всех, 609 | если они не фильтруются другими параметрами. 610 | * `non_owner_only` или `not_owner_only` — если передан любой из них 611 | (можно без значения), то в RSS-ленту выводятся лишь те записи, которые 612 | * в случае стены сообщества опубликованы не от имени сообщества, а пользователями; 613 | 614 | если в этом случае дополнительно передан параметр [`allow_signed`](#rus-sign) 615 | с отсутствующим значением или со значение`true`, то еще будут 616 | выводиться подписанные записи, опубликованные от имени сообщества; 617 | * в случае стены пользователя опубликованы не самим этим пользователем, а другими. 618 | 619 | *По умолчанию* (отсутствие параметра) выводятся записи ото всех, 620 | если они не фильтруются другими параметрами. 621 | * `allow_signed` — допускать или нет подписанные записи, опубликованные 622 | от имени сообщества, если передан параметр [`owner_only`](#rus-owner-only) 623 | или [`non_owner_only`/`not_owner_only`](#rus-non-owner-only). 624 | 625 | *По умолчанию* (отсутствие параметра) допустимы все записи, 626 | которые проходят фильтрацию другими параметрами. 627 | 628 | Допустимые значения (регистр не учитывается): [отсутствие значения] 629 | (аналог `true`), `true`, `false`, `0` (аналог `false`), 630 | `1` (аналог `true`), все остальные значения воспринимаются как `true`. 631 | * В случае переданного параметра [`owner_only`](#rus-owner-only) позволяет исключать 632 | подписанные записи путем передачи параметра `allow_signed` со значением `false`. 633 | * В случае переданного параметра [`non_owner_only` или `not_owner_only`](#rus-non-owner-only) 634 | позволяет дополнительно включать в RSS-ленту подписанные записи 635 | путем передачи параметра `allow_signed` со значением `true`, 636 | * `skip_ads` — если передан (можно без значения), 637 | то не выводить в RSS-ленту записи, помеченные как реклама. 638 | 639 | *По умолчанию* (отсутствие параметра) выводятся все записи, 640 | если они не фильтруются другими параметрами. 641 | 642 | **Примечание**: API Вконтакте помечает как рекламу не все записи, 643 | которые помечены на стене на сайте, поэтому некоторые рекламные посты параметр не убирает. 644 | * `allow_embedded_video` допускает или нет встраивание видеозаписей в описание RSS записей. 645 | 646 | *По умолчанию* [отсутствующий параметр] отключено. 647 | 648 | Когда включено, скрипт пытается получить ссылку на видеоплеер, 649 | но если [`access_token`](#rus-user-access-token) не имеет разрешения `video`, 650 | то этот параметр принудительно принимает значение `false`. 651 | 652 | Если скрипту удается получить ссылку на видеоплеер, 653 | тогда при включенном HTML форматировании видеозаписи можно проигрывать в читателе RSS, 654 | в противном случае видеозаписи отображаются либо в виде кликабельных изображений-превью при включенном HTML форматировании, 655 | либо в виде текста при отключенном HTML форматировании. 656 | * `proxy` — адрес прокси-сервера. Допустимые форматы значения этого параметра: 657 | * `address`, 658 | * `address:port`, 659 | * `type://address`, 660 | * `type://address:port`, 661 | * `login:password@address`, 662 | * `login:password@address:port`, 663 | * `type://login:password@address`, 664 | * `type://login:password@address:port`, 665 | 666 | где `address` — домен или IP-адрес прокси, `port` — порт, 667 | `type` — тип прокси (HTTPS, SOCKS4, SOCKS4A, SOCKS5), 668 | `login` и `password` — логин и пароль для доступа к прокси, если необходимы. 669 | 670 | Тип прокси и параметры авторизации можно передавать в виде отдельных параметров: 671 | * `proxy_type` — тип прокси (по умолчанию считается HTTP, если не указано в `proxy` и `proxy_type`), 672 | * `proxy_login` — логин для доступа к прокси-серверу, 673 | * `proxy_password` — пароль для доступа к прокси-серверу. 674 | 675 | 676 | ## Как получить бессрочный токен для доступа к стенам, которые доступны своему профилю 677 | Для серверного доступа предпочтительна [такая схема](https://vk.com/dev/authcode_flow_user): 678 | 679 | 1. Создать собственное standalone-приложение [по этой ссылке](https://vk.com/editapp?act=create). 680 | По желанию после генерации ключей в настройках приложения можно изменить состояние 681 | на «Приложение отключено» — это никак не помешает генерации RSS-ленты. 682 | 683 | **ВАЖНОЕ**: в настоящее время у [новосозданных приложений](https://id.vk.com/business/go) требуется ввод паспортных данных 684 | или данных об организации в личном кабинете разработчика, чтобы приложение могло получать доступ к нужным правам. 685 | 686 | При этом [старые standalone-приложения](https://vk.com/apps?act=manage) по-прежнему работают 687 | и дают возможность генерировать ключ доступа со всеми нужными правами. 688 | 689 | 2. После авторизации под нужным профилем пройти по ссылке: 690 | 691 | `https://oauth.vk.com/authorize?client_id=APP_ID&scope=offline,video,wall,friends&redirect_uri=https%3A%2F%2Foauth.vk.com%2Fblank.html&display=page&response_type=token&revoke=1` 692 | 693 | где вместо `APP_ID` подставить ID созданного приложения — его можно увидеть, 694 | например, в настройках приложения. 695 | 696 | Указанные в `scope` права доступа включают в себя следующие: 697 | * `offline` — обязательно для формирования бессрочного ключа доступа, 698 | * `video` — для работы параметра [`allow_embedded_video`](#rus-videos), 699 | * `wall` и `friends` — для работы параметра [`news_type`](#rus-newsfeed). 700 | 701 | Если указанные параметры скрипта не будут использоваться, то соответствующие права можно убрать из ссылки. 702 | 703 | 3. Подтвердить права. В результате в адресной строке будет указан ключ доступа `access_token` — 704 | именно это значение и следует использовать в качестве GET-параметра скрипта, генерирующего RSS-ленту. 705 | 706 | 4. При первом использовании токена с IP адреса, отличного от того, 707 | с которого получался токен, может выскочить ошибка "API Error 17: Validation required", 708 | требующая валидации: для этого необходимо пройти по первой ссылке из описания ошибки 709 | и ввести недостающие цифры номера телефона профиля. 710 | 711 | В качестве бонуса в статистике созданного приложения можно смотреть частоту запросов к API. 712 | 713 | **Внимание!** Если в настройках безопасности профиля будут завершены сессии приложения, 714 | то все сгенерированные через это приложение токены станут невалидными — 715 | нужно сформировать новый токен, повторив пункты 2-4. 716 | 717 | 718 | ## Примеры использования: 719 | ```php 720 | index.php?id=apiclub&access_token=XXXXXXXXX 721 | index.php?id=-1&access_token=XXXXXXXXX 722 | index.php?id=id1&access_token=XXXXXXXXX 723 | index.php?id=club1&access_token=XXXXXXXXX 724 | index.php?id=club1&disable_html&access_token=XXXXXXXXX # в данных RSS-ленты отсутстуют HTML-сущности 725 | index.php?id=apiclub&count=100&include=рекомендуем&access_token=XXXXXXXXX # выводятся только записи со словом 'рекомендуем' 726 | index.php?id=apiclub&count=100&exclude=рекомендуем&access_token=XXXXXXXXX # выводятся только записи без слова 'рекомендуем' 727 | index.php?id=apiclub&proxy=localhost:8080&access_token=XXXXXXXXX 728 | index.php?id=apiclub&proxy=localhost:8080&proxy_type=https&access_token=XXXXXXXXX 729 | index.php?id=apiclub&proxy=https%3A%2F%2Flocalhost:8080&access_token=XXXXXXXXX 730 | index.php?id=club1&owner_only&access_token=XXXXXXXXX # выводятся только записи от имени сообщества 731 | index.php?id=club1&owner_only&allow_signed=false&access_token=XXXXXXXXX # выводятся только записи от имени сообщества, 732 | # у которых нет подписи 733 | index.php?id=club1&non_owner_only&access_token=XXXXXXXXX # выводятся только записи от пользователей (не от имени сообщества) 734 | index.php?id=club1&non_owner_only&allow_signed&access_token=XXXXXXXXX # выводятся только записи от имени сообщества, 735 | # у которых есть подпись, и записи от пользователей 736 | index.php?id=club1&allow_embedded_video&access_token=XXXXXXXXX # встраивает проигрываемые видеозаписи в описание записи 737 | index.php?id=-1&count=30&repost_delimiter=

{author} пишет:&access_token=XXXXXXXXX 738 | index.php?id=pitertransport&donut&access_token=XXXXXXXXX # Помимо обычных записей, в RSS ленту добавляются записи для донов 739 | index.php?news_type=recent&count=25&access_token=XXXXXXXXX # 25 самых свежих записей из новостной ленты 740 | index.php?news_type=recent&news_sources=list1&count=20&access_token=XXXXXXXXX # 20 самых свежих записей из самостоятельно 741 | # созданной новостной ленты под идентификатором 1 742 | index.php?news_type=recommended&count=30&access_token=XXXXXXXXX # 30 рекомендуемых записей из новостной ленты 743 | index.php?global_search=запрос&count=300&access_token=XXXXXXXXX # поиск записей, содержащих слово "запрос" 744 | index.php?id=-1&count=100&include=(рекомендуем|приглашаем|\d+)&access_token=XXXXXXXXX 745 | ``` 746 | **Примечание**: в последних двух примерах при таком вызове напрямую через 747 | GET-параметры может потребоваться URL-кодирование символов у параметров `global_search`, `include` и им подобным: 748 | ```index.php?id=-1&count=100&include=(%D1%80%D0%B5%D0%BA%D0%BE%D0%BC%D0%B5%D0%BD%D0%B4%D1%83%D0%B5%D0%BC%7C%D0%BF%D1%80%D0%B8%D0%B3%D0%BB%D0%B0%D1%88%D0%B0%D0%B5%D0%BC%7C%5Cd%2B)&access_token=XXXXXXXXX``` 749 | 750 | ## Возможные проблемы и их решения 751 | * Если при запуске скрипта интерпретатор выдает ошибку: 752 | > date(): It is not safe to rely on the system's timezone settings. 753 | You are *required* to use the date.timezone setting or 754 | the date_default_timezone_set() function. In case you used any 755 | of those methods and you are still getting this warning, 756 | you most likely misspelled the timezone identifier. 757 | We selected the timezone 'UTC' for now, but please set date.timezone 758 | to select your timezone. 759 | 760 | тогда необходимо либо добавить информацию о часовом поясе 761 | в конфигурационный файл PHP (параметр `date.timezone`), 762 | либо добавить в начале скрипта `index.php` (перед `require_once`) строку, 763 | подобную `date_default_timezone_set('UTC');`, 764 | устанавливающую часовую зону `UTC` для скрипта. 765 | * Если при изменении количества комментариев к записи в вашем агрегаторе RSS-лент 766 | запись помечается/ранжируется как новая/обновленная, то для такого случая 767 | есть возможность отключить счетчик комментариев, 768 | добавив GET-параметр [`disable_comments_amount`](#rus-comments-counter): 769 | 770 | ```index.php?id=-1&disable_comments_amount``` 771 | или 772 | ```index.php?id=-1&disable_comments_amount=1``` 773 | * Если при использовании параметра [`donut`](#rus-donut) в результате появляется ошибка **API Error 15**, 774 | то это значит, что: 775 | * либо у пользователя, чей ключ доступа использован, не оформлена подписка на VK Donut сообщества 776 | (стоит удостовериться, что в качестве [`access_token`](#rus-access-token) используется именно ключ доступа пользователя, 777 | у которого есть подписка VK Donut на сообщество с верным [`id`](#rus-id)); 778 | * либо у сообщества отключен VK Donut; 779 | * либо RSS-лента генерируется для стены пользователя, а не сообщества. 780 | -------------------------------------------------------------------------------- /Vk2rss.php: -------------------------------------------------------------------------------- 1 | 9 | **/ 10 | 11 | /** 12 | * Refactoring and featuring: 13 | * including & excluding conditions, 14 | * title generation, 15 | * description extraction from attachment, 16 | * hashtags extractor to 'category' tags, 17 | * author extraction 18 | * @author woxcab 19 | **/ 20 | 21 | require_once('utils.php'); 22 | 23 | set_error_handler('exceptions_error_handler'); 24 | 25 | function exceptions_error_handler($severity, $message, $filename, $lineno) { 26 | if (error_reporting() == 0) { 27 | return; 28 | } 29 | if (error_reporting() & $severity) { 30 | throw new ErrorException($message, 0, $severity, $filename, $lineno); 31 | } 32 | } 33 | 34 | class Vk2rss 35 | { 36 | const HASH_TAG_PATTERN = '#([а-яёА-ЯЁa-zA-Z0-9_]+)(?:@[a-zA-Z0-9_]+)?'; 37 | const TEXTUAL_LINK_PATTERN = '@(?:\[((?:https?://)?(?:m\.)?vk\.com/[^|]*)\|([^\]]+)\]|(\s*)\b(https?://\S+?)(?=[.,!?;:»”’"]?(?:\s|$))|(\()(https?://\S+?)(\))|(\([^(]*?)(\s*)\b(\s*https?://\S+?)([.,!?;:»”’"]?\s*\)))@u'; 38 | const TEXTUAL_LINK_REPLACE_PATTERN = '$3$5$8$9$2$4$6${10}$7${11}'; 39 | const TEXTUAL_LINK_REMOVE_PATTERN = '$2$5$8'; 40 | const EMPTY_STRING_PATTERN = '@^(?:[\s ]*[\r\n]+[\s ]*)*$@u'; 41 | 42 | /** 43 | * Default title value when no text in the post 44 | */ 45 | const EMPTY_POST_TITLE = '[Без текста]'; 46 | /** 47 | * Prefix of feed description for user wall 48 | */ 49 | const USER_FEED_DESCRIPTION_PREFIX = "Стена пользователя "; 50 | /** 51 | * Prefix of feed description for group wall 52 | */ 53 | const GROUP_FEED_DESCRIPTION_PREFIX = "Стена сообщества "; 54 | /** 55 | * Feed title and description prefix when global searching is performed 56 | */ 57 | const GLOBAL_SEARCH_FEED_TITLE_PREFIX = "Результаты поиска по запросу "; 58 | /** 59 | * Recent news title 60 | */ 61 | const RECENT_NEWS_TITLE_PREFIX = "Новости для "; 62 | /** 63 | * Recommended news title 64 | */ 65 | const RECOMMENDED_NEWS_TITLE_PREFIX = "Рекомендуемое для "; 66 | /** 67 | * Video title 68 | */ 69 | const VIDEO_TITLE_PREFIX = "Видеозапись"; 70 | /** 71 | * Audio record title 72 | */ 73 | const AUDIO_TITLE_PREFIX = "Аудиозапись"; 74 | /** 75 | * Image title 76 | */ 77 | const IMAGE_TITLE_PREFIX = "Изображение"; 78 | /** 79 | * Album title 80 | */ 81 | const ALBUM_TITLE_PREFIX = "Альбом"; 82 | /** 83 | * Non-image file title 84 | */ 85 | const FILE_TITLE_PREFIX = "Файл"; 86 | /** 87 | * Title of repost (in the prepositional case) that's written by community 88 | */ 89 | const COMMUNITY_REPOST_TITLE_ABL = "в сообществе"; 90 | /** 91 | * Maximum title length in symbols 92 | */ 93 | const MAX_TITLE_LENGTH = 80; 94 | /** 95 | * Required minimum number of symbols in second or following paragraphs in order to use its for title 96 | */ 97 | const MIN_PARAGRAPH_LENGTH_FOR_TITLE = 30; 98 | /** 99 | * URL of API method that returns wall posts 100 | */ 101 | const API_BASE_URL = 'https://api.vk.com/method/'; 102 | 103 | /** 104 | * @var stdClass profile of access token's owner 105 | */ 106 | protected $profile; 107 | /** 108 | * @var int identifier of user or group which wall going to be extracted 109 | */ 110 | protected $owner_id; 111 | /** 112 | * @var string short address of user or group which wall going to be extracted 113 | */ 114 | protected $domain; 115 | /** 116 | * @var string search query to lookup on all opened walls 117 | */ 118 | protected $global_search; 119 | /** 120 | * @var string type of extracting news feed: recent or recommended 121 | */ 122 | protected $news_type; 123 | /** 124 | * @var string comma separated list of recent news' sources 125 | */ 126 | protected $news_sources; 127 | /** 128 | * @var int quantity of last posts from the wall (at most 100) 129 | */ 130 | protected $count; 131 | /** 132 | * @var string case-insensitive regular expression that have to match text of post 133 | */ 134 | protected $include; 135 | /** 136 | * @var string case-insensitive regular expression that have not to match text of post 137 | */ 138 | protected $exclude; 139 | /** 140 | * @var bool whether the HTML tags to be used in the feed item description 141 | */ 142 | protected $disable_html; 143 | 144 | /** 145 | * @var bool whether (comments amount) should be presented in the feed item 146 | */ 147 | protected $disable_comments_amount; 148 | /** 149 | * @var bool whether the post should be published by community/profile owner only 150 | */ 151 | protected $owner_only; 152 | /** 153 | * @var bool whether the post should be published by anyone except community/profile owner 154 | */ 155 | protected $non_owner_only; 156 | /** 157 | * @var bool whether posts (that's published by community) with signature are allowed 158 | * when owner_only or non_owner_only/not_owner_only parameter is passed 159 | */ 160 | protected $allow_signed; 161 | /** 162 | * @var bool whether posts are skipped that's marked as ad 163 | */ 164 | protected $skip_ads; 165 | /** 166 | * @var string text or HTML formatted string that's placed between parent and child posts 167 | */ 168 | protected $repost_delimiter; 169 | /** 170 | * @var string|null Service token to get access to opened walls (there's in app settings) 171 | * or user access token to get access to closed walls that opened for token creator 172 | */ 173 | protected $access_token; 174 | 175 | /** 176 | * @var ProxyDescriptor|null Proxy descriptor 177 | */ 178 | protected $proxy = null; 179 | 180 | /** 181 | * @var string text or HTML formatted string that's placed between post' content and the first attachment, 182 | * and between attachments 183 | */ 184 | protected $attachment_delimiter; 185 | /** 186 | * @var string regular expression that matches delimiter 187 | */ 188 | protected $delimiter_regex; 189 | 190 | /** 191 | * @var bool whether the access token has video permission and video embedding is enabled 192 | */ 193 | protected $allow_embedded_video; 194 | 195 | /** 196 | * @var bool $donut whether include donut posts to the feed or not 197 | */ 198 | protected $donut; 199 | 200 | /** 201 | * @var ConnectionWrapper 202 | */ 203 | protected $connector; 204 | 205 | /** 206 | * Vk2rss constructor. 207 | * @param array $config Parameters from the set: id, access_token, 208 | * count, include, exclude, disable_html, owner_only, non_owner_only, 209 | * disable_comments_amount, allow_signed, skip_ads, repost_delimiter, 210 | * proxy, proxy_type, proxy_login, proxy_password 211 | * where id and access_token are required 212 | * @throws Exception If required parameters id or access_token do not present in the configuration 213 | * or proxy parameters are invalid 214 | * or news feed type is invalid 215 | */ 216 | public function __construct($config) 217 | { 218 | if ((!empty($config['id']) + !empty($config['global_search']) + !empty($config['news_type']) !== 1) 219 | || empty($config['access_token'])) { 220 | throw new Exception("Access/service token with identifier of user or community ". 221 | "OR global search query OR newsfeed type must be passed", 400); 222 | } 223 | 224 | if (isset($config['proxy'])) { 225 | try { 226 | $this->proxy = new ProxyDescriptor($config['proxy'], 227 | isset($config['proxy_type']) ? $config['proxy_type'] : null, 228 | isset($config['proxy_login']) ? $config['proxy_login'] : null, 229 | isset($config['proxy_password']) ? $config['proxy_password'] : null); 230 | } catch (Exception $exc) { 231 | throw new Exception("Invalid proxy parameters: " . $exc->getMessage(), 400); 232 | } 233 | } 234 | $this->connector = new ConnectionWrapper($this->proxy); 235 | 236 | $this->profile = null; 237 | $this->access_token = $config['access_token']; 238 | $id = $config['id']; 239 | if (empty($id)) { 240 | $user_response = $this->getContent('users.get'); 241 | if (property_exists($user_response, 'error')) { 242 | throw new APIError($user_response, $this->connector->getLastUrl()); 243 | } 244 | $this->profile = $user_response->response[0]; 245 | $this->owner_id = $this->profile->id; 246 | $this->domain = null; 247 | } elseif (strcmp(substr($id, 0, 2), 'id') === 0 && ctype_digit(substr($id, 2))) { 248 | $this->owner_id = (int)substr($id, 2); 249 | $this->domain = null; 250 | } elseif (strcmp(substr($id, 0, 4), 'club') === 0 && ctype_digit(substr($id, 4))) { 251 | $this->owner_id = -(int)substr($id, 4); 252 | $this->domain = null; 253 | } elseif (strcmp(substr($id, 0, 5), 'event') === 0 && ctype_digit(substr($id, 5))) { 254 | $this->owner_id = -(int)substr($id, 5); 255 | $this->domain = null; 256 | } elseif (strcmp(substr($id, 0, 6), 'public') === 0 && ctype_digit(substr($id, 6))) { 257 | $this->owner_id = -(int)substr($id, 6); 258 | $this->domain = null; 259 | } elseif (is_numeric($id) && is_int(abs($id))) { 260 | $this->owner_id = (int)$id; 261 | $this->domain = null; 262 | } else { 263 | $this->owner_id = null; 264 | $this->domain = $id; 265 | } 266 | $this->global_search = empty($config['global_search']) ? null : $config['global_search']; 267 | if (empty($config['news_type'])) { 268 | $this->news_type = null; 269 | $this->news_sources = null; 270 | } else { 271 | if ($config['news_type'] !== 'recent' && $config['news_type'] !== 'recommended') { 272 | throw new Exception('Bad news type. Allowed values: "recent" or "recommended"', 400); 273 | } 274 | $this->news_type = $config['news_type']; 275 | if (!empty($config['news_sources'])) { 276 | if ($this->news_type !== 'recent') { 277 | throw new Exception('News sources can be used for recent news only', 400); 278 | } 279 | if (preg_match("/^(?:friends|groups|pages|following|(?:u|g|list|-)?\d+)(?:,(?:friends|groups|pages|following|(?:u|g|list|-)?\d+))*$/", 280 | $config['news_sources']) !== 1) { 281 | throw new Exception('Bad news sources: each comma separated value must be ' . 282 | 'one of "friends", "groups", "pages", "following",' . 283 | '"", "u", "-", "g" or "list"', 284 | 400); 285 | } 286 | $this->news_sources = $config['news_sources']; 287 | } else { 288 | $this->news_sources = null; 289 | } 290 | } 291 | $this->count = empty($config['count']) ? 20 : $config['count']; 292 | $this->include = isset($config['include']) && $config['include'] !== '' 293 | ? preg_replace("/(?exclude = isset($config['exclude']) && $config['exclude'] !== '' 295 | ? preg_replace("/(?disable_html = logical_value($config, 'disable_html'); 297 | $this->disable_comments_amount = logical_value($config, 'disable_comments_amount'); 298 | $this->owner_only = logical_value($config, 'owner_only'); 299 | $this->non_owner_only = logical_value($config, 'non_owner_only') || logical_value($config, 'not_owner_only'); 300 | $this->allow_signed = logical_value($config, 'allow_signed'); 301 | $this->skip_ads = logical_value($config, 'skip_ads'); 302 | $this->allow_embedded_video = logical_value($config, 'allow_embedded_video'); 303 | $this->repost_delimiter = isset($config['repost_delimiter']) 304 | ? $config['repost_delimiter'] 305 | : ($this->disable_html ? "______________________" : "

"); 306 | $this->attachment_delimiter = $this->disable_html ? "______________________" : "
"; 307 | if (preg_match('/\{author[^}]*\}/', $this->repost_delimiter) === 1) { 308 | $this->delimiter_regex = '/^' . preg_quote($this->attachment_delimiter, '/') . '$/u'; 309 | } else { 310 | $this->delimiter_regex = '/^(' . preg_quote($this->repost_delimiter, '/') 311 | . '|' . preg_quote($this->attachment_delimiter, '/') . '$)/u'; 312 | } 313 | if (isset($config['donut'])) { 314 | $this->donut = logical_value($config, 'donut'); 315 | } 316 | } 317 | 318 | /** 319 | * Generate RSS feed as output 320 | * 321 | * @throws Exception 322 | */ 323 | public function generateRSS() 324 | { 325 | include('FeedWriter.php'); 326 | 327 | $outer_encoding = mb_internal_encoding(); 328 | if (!mb_internal_encoding("UTF-8")) { 329 | throw new Exception("Cannot set encoding UTF-8 for multibyte strings", 500); 330 | } 331 | 332 | $feed = new FeedWriter(RSS2); 333 | 334 | if(!empty($this->news_type) ) { 335 | $path = ($this->news_type === 'recent') ? 'feed' : 'feed?section=recommended'; 336 | } else { 337 | $path = $this->domain ?: ($this->owner_id > 0 ? 'id' . $this->owner_id : 'club' . abs($this->owner_id)); 338 | } 339 | 340 | $feed->setLink('https://vk.com/' . $path); 341 | 342 | $feed->setChannelElement('language', 'ru-ru'); 343 | $feed->setChannelElement('pubDate', date(DATE_RSS, time())); 344 | 345 | $profiles = array(); 346 | if (!empty($this->profile)) { 347 | $profiles[$this->profile->id] = $this->profile; 348 | $profiles[$this->profile->screen_name] = $this->profile; 349 | } 350 | $groups = array(); 351 | $next_from = null; 352 | $offset_step = empty($this->global_search) ? 100 : 200; 353 | 354 | if (empty($this->global_search) && empty($this->news_type) && $this->donut) { 355 | for ($offset = 0; $offset < $this->count; $offset += $offset_step) { 356 | $wall_response = $this->getContent("wall.get", $offset, true); 357 | if (!$this->processWallResponse($feed, $wall_response, $profiles, $groups)) { 358 | break; 359 | } 360 | } 361 | } 362 | 363 | for ($offset = 0; $offset < $this->count; $offset += $offset_step) { 364 | if (!empty($this->news_type)) { 365 | $method_name = $this->news_type == 'recent' ? 'newsfeed.get' : 'newsfeed.getRecommended'; 366 | $wall_response = $this->getContent($method_name, $next_from); 367 | } elseif (!empty($this->global_search)) { 368 | $wall_response = $this->getContent("newsfeed.search", $next_from); 369 | $next_from = empty($wall_response->response->next_from) 370 | ? null : $wall_response->response->next_from; 371 | } else { 372 | $wall_response = $this->getContent("wall.get", $offset); 373 | } 374 | if (!$this->processWallResponse($feed, $wall_response, $profiles, $groups)) { 375 | break; 376 | } 377 | 378 | if ((!empty($this->global_search) || !empty($this->news_type)) 379 | && is_null($next_from)) { 380 | break; 381 | } 382 | } 383 | 384 | try { 385 | if (!empty($this->global_search)) { 386 | $feed_title = self::GLOBAL_SEARCH_FEED_TITLE_PREFIX . '"' . $this->global_search . '"'; 387 | $feed_description = $feed_title; 388 | } elseif (!empty($this->domain) && isset($profiles[$this->domain]) 389 | || (!empty($this->owner_id) && $this->owner_id > 0) 390 | ) { 391 | $profile = isset($profiles[$this->domain]) ? $profiles[$this->domain] : $profiles[$this->owner_id]; 392 | if (!empty($this->news_type)) { 393 | $feed_title = ($this->news_type === 'recent') 394 | ? self::RECENT_NEWS_TITLE_PREFIX 395 | : self::RECOMMENDED_NEWS_TITLE_PREFIX; 396 | $feed_title .= $profile->first_name_gen . ' ' . $profile->last_name_gen; 397 | $feed_description = $feed_title; 398 | } else { 399 | $feed_title = $profile->first_name . ' ' . $profile->last_name; 400 | $feed_description = self::USER_FEED_DESCRIPTION_PREFIX 401 | . $profile->first_name . ' ' . $profile->last_name; 402 | } 403 | } else { 404 | $group = isset($groups[$this->domain]) ? $groups[$this->domain] : $groups[abs($this->owner_id)]; 405 | $feed_title = $group->name; 406 | $feed_description = self::GROUP_FEED_DESCRIPTION_PREFIX . $group->name; 407 | } 408 | } catch (Exception $exc) { 409 | throw new Exception("Invalid user/group identifier, its wall is empty, " . 410 | "empty search result, or no news", 400, $exc); 411 | } 412 | 413 | $feed->setTitle($feed_title); 414 | $feed->setDescription($feed_description); 415 | 416 | $feed->generateFeed(); 417 | mb_internal_encoding($outer_encoding); 418 | } 419 | 420 | /** 421 | * @param FeedWriter $feed 422 | * @param $wall_response 423 | * @param array $profiles 424 | * @param array $groups 425 | * 426 | * @return bool Whether response contains at least one item or not 427 | * @throws \APIError 428 | */ 429 | protected function processWallResponse($feed, $wall_response, &$profiles, &$groups) { 430 | if (property_exists($wall_response, 'error')) { 431 | throw new APIError($wall_response, $this->connector->getLastUrl()); 432 | } 433 | if (empty($wall_response->response->items)) { 434 | return false; 435 | } 436 | 437 | $videos = array(); 438 | if ($this->allow_embedded_video) { 439 | $this->extractVideos($videos, $wall_response->response->items); 440 | foreach (array_chunk($videos, 200) as $videos_chunk) { 441 | $videos_str = join(",", array_map(function($v) { return empty($v->access_key) 442 | ? "{$v->owner_id}_{$v->id}" 443 | : "{$v->owner_id}_{$v->id}_{$v->access_key}"; }, 444 | $videos_chunk)); 445 | $videos_response = $this->getContent("video.get", null, false, array("videos" => $videos_str)); 446 | if (property_exists($videos_response, 'error')) { 447 | $error_code = $videos_response->error->error_code; 448 | if ($error_code == 204 || $error_code == 15) { 449 | $this->allow_embedded_video = false; 450 | break; 451 | } else { 452 | throw new APIError($videos_response, $this->connector->getLastUrl()); 453 | } 454 | } else { 455 | foreach ($videos_response->response->items as $video_info) { 456 | $videos["{$video_info->owner_id}_{$video_info->id}"] = $video_info; 457 | } 458 | } 459 | } 460 | } 461 | 462 | foreach ($wall_response->response->profiles as $profile) { 463 | if (!isset($profile->screen_name)) { 464 | $profile->screen_name = "id{$profile->id}"; 465 | } 466 | $profiles[$profile->screen_name] = $profile; 467 | $profiles[$profile->id] = $profile; 468 | } 469 | foreach ($wall_response->response->groups as $group) { 470 | if (!isset($group->screen_name)) { 471 | $group->screen_name = "club{$group->id}"; 472 | } 473 | $groups[$group->screen_name] = $group; 474 | $groups[$group->id] = $group; 475 | } 476 | 477 | foreach ($wall_response->response->items as $post) { 478 | if ($post->id == 0 479 | || $this->owner_only 480 | && ($post->owner_id != $post->from_id 481 | || property_exists($post, 'signer_id') && !$this->allow_signed) 482 | || $this->non_owner_only && $post->owner_id == $post->from_id 483 | && (!property_exists($post, 'signer_id') || !$this->allow_signed) 484 | || $this->skip_ads && $post->marked_as_ads) { 485 | continue; 486 | } 487 | $new_item = $feed->createNewItem(); 488 | $new_item->setLink("https://vk.com/wall{$post->owner_id}_{$post->id}"); 489 | $new_item->addElement('guid', "https://vk.com/wall{$post->owner_id}_{$post->id}"); 490 | $new_item->setDate($post->date); 491 | 492 | $description = array(); 493 | $this->extractDescription($description, $videos, $post, $profiles, $groups); 494 | if (!empty($description) && preg_match($this->delimiter_regex, $description[0]) === 1) { 495 | array_shift($description); 496 | } 497 | 498 | foreach ($description as &$paragraph) { 499 | // process internal short vk links like [id123|Name] 500 | if ($this->disable_html) { 501 | $paragraph = preg_replace('/\[([a-zA-Z0-9_]+)\|([^\]]+)\]/u', '$2 (https://vk.com/$1)', $paragraph); 502 | } else { 503 | $paragraph = preg_replace('/\[([a-zA-Z0-9_]+)\|([^\]]+)\]/u', '$2', $paragraph); 504 | } 505 | } 506 | 507 | $imploded_description = implode($this->disable_html ? PHP_EOL : '
', $description); 508 | $new_item->setDescription($imploded_description); 509 | 510 | if (isset($this->include) && preg_match('/' . $this->include . '/iu', $imploded_description) !== 1) { 511 | continue; 512 | } 513 | if (isset($this->exclude) && preg_match('/' . $this->exclude . '/iu', $imploded_description) !== 0) { 514 | continue; 515 | } 516 | 517 | $new_item->addElement('title', $this->getTitle($description)); 518 | $new_item->addElement("comments", "https://vk.com/wall{$post->owner_id}_{$post->id}"); 519 | if (!$this->disable_comments_amount) { 520 | $new_item->addElement("slash:comments", $post->comments->count); 521 | } 522 | if (isset($post->signer_id) && isset($profiles[$post->signer_id])) { # the 2nd owing to VK API bug 523 | $profile = $profiles[$post->signer_id]; 524 | $new_item->addElement('author', $profile->first_name . ' ' . $profile->last_name); 525 | } else { 526 | $base_post = isset($post->copy_history) ? end($post->copy_history) : $post; 527 | $from_id = isset($base_post->from_id) 528 | ? $base_post->from_id 529 | : (isset($base_post->source_id) ? $base_post->source_id : 0); 530 | if (isset($base_post->signer_id) && isset($profiles[$base_post->signer_id])) { # the 2nd owing to VK API bug 531 | $profile = $profiles[$base_post->signer_id]; 532 | $new_item->addElement('author', $profile->first_name . ' ' . $profile->last_name); 533 | } elseif ($from_id > 0) { 534 | $profile = $profiles[$from_id]; 535 | $new_item->addElement('author', $profile->first_name . ' ' . $profile->last_name); 536 | } elseif ($from_id < 0) { 537 | $group = $groups[abs($from_id)]; 538 | $new_item->addElement('author', $group->name); 539 | } elseif ($base_post->owner_id > 0) { 540 | $profile = $profiles[$base_post->owner_id]; 541 | $new_item->addElement('author', $profile->first_name . ' ' . $profile->last_name); 542 | } elseif ($base_post->owner_id < 0) { 543 | $group = $groups[abs($base_post->owner_id)]; 544 | $new_item->addElement('author', $group->name); 545 | } 546 | } 547 | 548 | preg_match_all('/' . self::HASH_TAG_PATTERN . '/u', implode(' ', $description), $hash_tags); 549 | 550 | foreach ($hash_tags[1] as $hash_tag) { 551 | $new_item->addElement('category', $hash_tag); 552 | } 553 | 554 | $feed->addItem($new_item); 555 | } 556 | return true; 557 | } 558 | 559 | protected function extractVideos(&$videos, &$posts) { 560 | foreach ($posts as $post) { 561 | if (isset($post->attachments)) { 562 | foreach ($post->attachments as $attachment) { 563 | if ($attachment->type === 'video') { 564 | $video = $attachment->video; 565 | $videos["{$video->owner_id}_{$video->id}"] = $video; 566 | } 567 | } 568 | } 569 | if (isset($post->copy_history)) { 570 | $this->extractVideos($videos, $post->copy_history); 571 | } 572 | } 573 | } 574 | 575 | protected function extractDescription(&$description, &$videos, $post, &$profiles, &$groups) 576 | { 577 | $par_split_regex = '@[\s ]*?[\r\n]+[\s ]*?@u'; # PHP 5.2.X: \s does not contain non-break space 578 | 579 | if (preg_match(self::EMPTY_STRING_PATTERN, $post->text) === 0) { 580 | $post_text = htmlspecialchars($post->text, ENT_NOQUOTES); 581 | if (!$this->disable_html) { 582 | $post_text = preg_replace(self::TEXTUAL_LINK_PATTERN, 583 | self::TEXTUAL_LINK_REPLACE_PATTERN, 584 | $post_text); 585 | } 586 | $description = array_merge($description, preg_split($par_split_regex, $post_text)); 587 | } 588 | 589 | if (isset($post->attachments)) { 590 | foreach ($post->attachments as $attachment) { 591 | switch ($attachment->type) { 592 | case 'photo': { 593 | list($preview_photo_src, $huge_photo_src) = $this->getPreviewAndOriginal($attachment->photo->sizes); 594 | $photo_text = preg_replace('|^Original: https?://\S+\s*|u', 595 | '', 596 | htmlspecialchars($attachment->photo->text, ENT_NOQUOTES)); 597 | if (!$this->disable_html) { 598 | $photo_text = preg_replace(self::TEXTUAL_LINK_PATTERN, 599 | self::TEXTUAL_LINK_REPLACE_PATTERN, 600 | $photo_text); 601 | } 602 | $photo = $this->disable_html 603 | ? $huge_photo_src 604 | : ""; 605 | if (preg_match(self::EMPTY_STRING_PATTERN, $photo_text) === 0) { 606 | $description = array_merge($description, 607 | array($this->attachment_delimiter), 608 | preg_split($par_split_regex, $photo_text), 609 | array($photo)); 610 | } else { 611 | $description[] = $photo; 612 | } 613 | break; 614 | } 615 | case 'album': { 616 | list($preview_thumb_src, $huge_thumb_src) = $this->getPreviewAndOriginal($attachment->album->thumb->sizes); 617 | $album_title = htmlspecialchars($attachment->album->title, ENT_NOQUOTES); 618 | $album_url = "https://vk.com/album" . $attachment->album->owner_id . "_" . $attachment->album->id; 619 | $description[] = $this->attachment_delimiter; 620 | if ($this->disable_html) { 621 | $description[] = self::ALBUM_TITLE_PREFIX . " «" . $album_title . "»: " . $album_url; 622 | } else { 623 | $description[] = "" . self::ALBUM_TITLE_PREFIX . " «" . $album_title . "»"; 624 | } 625 | $album_description = htmlspecialchars($attachment->album->description, ENT_NOQUOTES); 626 | if (!$this->disable_html) { 627 | $album_description = preg_replace(self::TEXTUAL_LINK_PATTERN, 628 | self::TEXTUAL_LINK_REPLACE_PATTERN, 629 | $album_description); 630 | } 631 | if (preg_match(self::EMPTY_STRING_PATTERN, $album_description) === 0) { 632 | $description = array_merge($description, preg_split($par_split_regex, $album_description)); 633 | } 634 | $thumb = $this->disable_html 635 | ? $huge_thumb_src 636 | : ""; 637 | $description[] = $thumb; 638 | break; 639 | } 640 | case 'audio': { 641 | $title = self::AUDIO_TITLE_PREFIX . " {$attachment->audio->artist} — «{$attachment->audio->title}»"; 642 | $description[] = htmlspecialchars($title, ENT_NOQUOTES); 643 | break; 644 | } 645 | case 'doc': { 646 | $url = parse_url($attachment->doc->url); 647 | parse_str($url['query'], $params); 648 | unset($params['api']); 649 | unset($params['dl']); 650 | $url['query'] = http_build_query($params); 651 | $url = build_url($url); 652 | $title = htmlspecialchars($attachment->doc->title, ENT_NOQUOTES); 653 | if (!empty($attachment->doc->preview->photo)) { 654 | list($preview_src, $huge_photo_src) = $this->getPreviewAndOriginal($attachment->doc->preview->photo->sizes); 655 | if ($this->disable_html) { 656 | $description[] = self::IMAGE_TITLE_PREFIX . " «{$title}»: {$huge_photo_src} ({$url})"; 657 | } else { 658 | array_push($description, 659 | $this->attachment_delimiter, 660 | "" . self::IMAGE_TITLE_PREFIX . " «{$title}»:", 661 | ""); 662 | } 663 | } else { 664 | if ($this->disable_html) { 665 | $description[] = self::FILE_TITLE_PREFIX . " «{$title}»: {$url}"; 666 | } else { 667 | $description[] = "" . self::FILE_TITLE_PREFIX . " «{$title}»"; 668 | } 669 | } 670 | break; 671 | } 672 | case 'link': { 673 | if ($this->disable_html) { 674 | array_push($description, 675 | $this->attachment_delimiter, 676 | "{$attachment->link->title}: {$attachment->link->url}"); 677 | } else { 678 | $link_text = preg_match(self::EMPTY_STRING_PATTERN, $attachment->link->title) === 0 ? 679 | htmlspecialchars($attachment->link->title, ENT_NOQUOTES) : $attachment->link->url; 680 | if (!empty($attachment->link->photo)) { 681 | list($preview_src, $_) = $this->getPreviewAndOriginal($attachment->link->photo->sizes); 682 | array_push($description, 683 | $this->attachment_delimiter, 684 | "", 685 | "{$link_text}"); 686 | } else { 687 | array_push($description, 688 | $this->attachment_delimiter, 689 | "{$link_text}"); 690 | } 691 | } 692 | if (!empty($attachment->link->description) 693 | && preg_match(self::EMPTY_STRING_PATTERN, $attachment->link->description) === 0) { 694 | $description[] = htmlspecialchars($attachment->link->description, ENT_NOQUOTES); 695 | } 696 | break; 697 | } 698 | case 'video': { 699 | $restricted = true; 700 | if (isset($attachment->video->restriction)) { 701 | $video_text = $attachment->video->restriction->text; 702 | } elseif (!empty($attachment->video->content_restricted)) { 703 | $video_text = $attachment->video->content_restricted_message; 704 | } else { 705 | $video_text = $attachment->video->description; 706 | $restricted = false; 707 | } 708 | 709 | $video_id = "{$attachment->video->owner_id}_{$attachment->video->id}"; 710 | $video_text = htmlspecialchars($video_text, ENT_NOQUOTES); 711 | if (!$this->disable_html) { 712 | $video_text = preg_replace(self::TEXTUAL_LINK_PATTERN, 713 | self::TEXTUAL_LINK_REPLACE_PATTERN, 714 | $video_text); 715 | } 716 | $video_description = preg_match(self::EMPTY_STRING_PATTERN, $video_text) === 1 ? 717 | array() : preg_split($par_split_regex, $video_text); 718 | if (empty($attachment->video->title)) { 719 | if ($this->disable_html) { 720 | $video_title = self::VIDEO_TITLE_PREFIX . ":"; 721 | } else { 722 | $video_title = "" 723 | . self::VIDEO_TITLE_PREFIX . ":"; 724 | } 725 | } elseif ($this->disable_html) { 726 | $video_title = self::VIDEO_TITLE_PREFIX . " «{$attachment->video->title}»:"; 727 | } else { 728 | $video_title = self::VIDEO_TITLE_PREFIX 729 | . " «{$attachment->video->title}»:"; 730 | } 731 | $content = array($video_title); 732 | if ($video_description) { 733 | array_unshift($content, $this->attachment_delimiter); 734 | } 735 | 736 | $playable = !$restricted && !empty($videos[$video_id]) && !empty($videos[$video_id]->player); 737 | $video_url = $playable ? $videos[$video_id]->player : "https://vk.com/video{$video_id}"; 738 | if ($this->disable_html) { 739 | $content[] = $video_url; 740 | } else { 741 | if ($playable) { 742 | $content[] = ""; 743 | } else { 744 | list($preview_src, $_) = $this->getPreviewAndOriginal($attachment->video->image); 745 | $content[] = ""; 746 | } 747 | } 748 | $description = array_merge($description, $content, $video_description); 749 | break; 750 | } 751 | case 'page': 752 | if ($this->disable_html) { 753 | array_push($description, 754 | $this->attachment_delimiter, 755 | "{$attachment->page->title}: https://vk.com/page-{$attachment->page->group_id}_{$attachment->page->id}"); 756 | } else { 757 | array_push($description, 758 | $this->attachment_delimiter, 759 | "{$attachment->page->title}"); 760 | } 761 | break; 762 | } 763 | } 764 | } 765 | 766 | if (isset($post->copy_history)) { 767 | foreach ($post->copy_history as $repost) { 768 | $author_id = isset($repost->signer_id) && isset($profiles[$repost->signer_id]) 769 | ? $repost->signer_id : $repost->owner_id; 770 | if ($author_id < 0) { 771 | $author_name = $groups[abs($author_id)]->name; 772 | $author_link = 'https://vk.com/' . $groups[abs($author_id)]->screen_name; 773 | } else { 774 | $author_name = $profiles[$author_id]->first_name . ' ' 775 | . $profiles[$author_id]->last_name; 776 | $author_name_ins = $profiles[$author_id]->first_name_ins . ' ' 777 | . $profiles[$author_id]->last_name_ins; 778 | $author_name_gen = $profiles[$author_id]->first_name_gen . ' ' 779 | . $profiles[$author_id]->last_name_gen; 780 | $author_link = 'https://vk.com/' . $profiles[$author_id]->screen_name; 781 | $author_ins = $this->disable_html 782 | ? "$author_name_ins ($author_link)" 783 | : "$author_name_ins"; 784 | $author_gen = $this->disable_html 785 | ? "$author_name_gen ($author_link)" 786 | : "$author_name_gen"; 787 | } 788 | $author = $this->disable_html 789 | ? "$author_name ($author_link)" 790 | : "$author_name"; 791 | if (!isset($author_ins)) { 792 | $author_ins = $author; 793 | } 794 | if (!isset($author_gen)) { 795 | $author_gen = $author; 796 | } 797 | if (isset($repost->signer_id)) { 798 | $repost_owner = $groups[abs($repost->owner_id)]->name; 799 | $repost_owner_url = "https://vk.com/{$groups[abs($repost->owner_id)]->screen_name}"; 800 | if ($this->disable_html) { 801 | $repost_place = " " .self::COMMUNITY_REPOST_TITLE_ABL . " $repost_owner ($repost_owner_url)"; 802 | } else { 803 | $repost_place = " " . self::COMMUNITY_REPOST_TITLE_ABL . " $repost_owner"; 804 | } 805 | $author .= $repost_place; 806 | $author_gen .= $repost_place; 807 | $author_ins .= $repost_place; 808 | } 809 | $repost_delimiter = preg_replace('/\{author\}/u', $author, $this->repost_delimiter); 810 | $repost_delimiter = preg_replace('/\{author_ins\}/u', $author_ins, $repost_delimiter); 811 | $repost_delimiter = preg_replace('/\{author_gen\}/u', $author_gen, $repost_delimiter); 812 | $description[] = $repost_delimiter; 813 | $this->extractDescription($description, $videos, $repost, $profiles, $groups); 814 | if (preg_match($this->delimiter_regex, end($description)) === 1) { 815 | array_pop($description); 816 | } 817 | } 818 | } 819 | } 820 | 821 | /** 822 | * Get posts of wall 823 | * 824 | * @param string $api_method API method name 825 | * @param int $offset offset for wall.get 826 | * @param bool $donut whether retrieve donut only posts or others 827 | * @param array $params additional key-value request parameters 828 | * 829 | * @return mixed Json VK response in appropriate PHP type 830 | * @throws Exception If unsupported API method name is passed or data retrieving is failed 831 | */ 832 | protected function getContent($api_method, $offset = null, $donut = false, 833 | $params = array('extended'=> '1', 'fields' => 'first_name_ins,last_name_ins,first_name_gen,last_name_gen,screen_name')) 834 | { 835 | $url = self::API_BASE_URL . $api_method . '?v=5.131'; 836 | if (isset($this->access_token)) { 837 | $url .= "&access_token={$this->access_token}"; 838 | } 839 | switch ($api_method) { 840 | case "users.get": 841 | unset($params['extended']); 842 | break; 843 | case "wall.get": 844 | if (!empty($this->domain)) { 845 | $url .= "&domain={$this->domain}"; 846 | } else { 847 | $url .= "&owner_id={$this->owner_id}"; 848 | } 849 | $default_count = 100; 850 | if (!empty($offset)) { 851 | $url .= "&offset={$offset}"; 852 | } 853 | if ($donut) { 854 | $url .= "&filter=donut"; 855 | } 856 | break; 857 | case "newsfeed.search": 858 | $url .= "&q=" . urlencode($this->global_search); 859 | $default_count = 200; 860 | if (!empty($offset)) { 861 | $url .= "&start_from={$offset}"; 862 | } 863 | break; 864 | case "newsfeed.getRecommended": 865 | case "newsfeed.get": 866 | $default_count = 100; 867 | $params['filters'] = 'post'; 868 | if ($this->news_sources) { 869 | $params['source_ids'] = $this->news_sources; 870 | } 871 | if (!empty($offset)) { 872 | $url .= "&start_from={$offset}"; 873 | } 874 | break; 875 | case "video.get": 876 | $default_count = 200; 877 | if (!empty($offset)) { 878 | $url .= "&offset={$offset}"; 879 | } 880 | break; 881 | default: 882 | throw new Exception("Passed unsupported VK API method name '{$api_method}'", 400); 883 | } 884 | foreach ($params as $key => $value) { 885 | $url .= "&{$key}={$value}"; 886 | } 887 | 888 | if ($api_method !== "users.get") { 889 | $total_count = ($api_method === "video.get") ? 200 : $this->count; 890 | if (!empty($offset) && mb_substr($api_method, 0, 8) !== "newsfeed") { 891 | $count = min($total_count - $offset, $default_count); 892 | } else { 893 | $count = min($total_count, $default_count); 894 | } 895 | $url .= "&count={$count}"; 896 | } 897 | 898 | $this->connector->openConnection(); 899 | try { 900 | $content = $this->connector->getContent($url, null, true); 901 | $this->connector->closeConnection(); 902 | return json_decode($content); 903 | } catch (Exception $exc) { 904 | $this->connector->closeConnection(); 905 | throw new Exception("Failed to get content of URL {$url}: " . $exc->getMessage(), $exc->getCode()); 906 | } 907 | } 908 | 909 | /** 910 | * Generate title using text of post 911 | * 912 | * @param array $raw_description post paragraphs 913 | * @return string generated title 914 | */ 915 | protected function getTitle($raw_description) 916 | { 917 | if (empty($raw_description)) { 918 | return self::EMPTY_POST_TITLE; 919 | } 920 | $description = array_fill(0, count($raw_description), null); 921 | foreach ($raw_description as $par_idx => $par) { 922 | $description[$par_idx] = $par; 923 | } 924 | $repost_delimiter_regex = '/^' . preg_replace('/\\\{author[^}]*\\\}/u', '.*', preg_quote($this->repost_delimiter, '/')) . '$/su'; 925 | foreach ($description as $par_idx => &$paragraph) { 926 | if (preg_match('/^\s*(?:' . self::HASH_TAG_PATTERN . '\s*)*$/u', $paragraph) === 1 // paragraph contains only hash tags 927 | || preg_match($this->delimiter_regex, $paragraph) === 1 928 | || preg_match($repost_delimiter_regex, $paragraph) === 1) { 929 | unset($description[$par_idx]); 930 | continue; 931 | } 932 | if (!$this->disable_html) { 933 | $paragraph = preg_replace('/<[^>]+?>/u', '', $paragraph); // remove all tags 934 | $paragraph = strtr($paragraph, array('<' => '<', 935 | '>' => '>', 936 | '"' => '"' , 937 | ''' => '\'')); 938 | } 939 | if (preg_match($this->delimiter_regex, $paragraph) === 1) { 940 | $paragraph = ""; 941 | } else { 942 | $paragraph = trim(preg_replace(self::TEXTUAL_LINK_PATTERN, self::TEXTUAL_LINK_REMOVE_PATTERN, $paragraph)); 943 | } 944 | } 945 | if (preg_match('/^\s*$/u', implode(PHP_EOL, $description)) === 1) { 946 | return self::EMPTY_POST_TITLE; 947 | } 948 | 949 | if (!function_exists('remove_underscores_from_hash_tag')) { 950 | function remove_underscores_from_hash_tag($match) 951 | { 952 | return str_replace('_', ' ', $match[1]); 953 | } 954 | } 955 | 956 | $curr_title_length = 0; 957 | $slice_length = 0; 958 | 959 | foreach ($description as $par_idx => &$paragraph) { 960 | // hash tags (if exist) are semantic part of paragraph 961 | $paragraph = preg_replace_callback('/' . self::HASH_TAG_PATTERN . '/u', 962 | 'remove_underscores_from_hash_tag', # anonymous function only in PHP>=5.3.0 963 | $paragraph); 964 | 965 | if ($curr_title_length < self::MAX_TITLE_LENGTH) { 966 | if (preg_match(self::EMPTY_STRING_PATTERN, $paragraph) === 1) { 967 | unset($description[$par_idx]); 968 | continue; 969 | } 970 | if(mb_strlen($paragraph) >= self::MIN_PARAGRAPH_LENGTH_FOR_TITLE 971 | || $curr_title_length + self::MIN_PARAGRAPH_LENGTH_FOR_TITLE < self::MAX_TITLE_LENGTH) { 972 | if (!in_array(mb_substr($paragraph, -1), array('.', '!', '?', ',', ':', ';'))) { 973 | $paragraph .= '.'; 974 | } 975 | $curr_title_length += mb_strlen($paragraph); 976 | $slice_length += 1; 977 | } else { 978 | break; 979 | } 980 | } 981 | } 982 | 983 | $full_title = implode(' ', array_slice($description, 0, $slice_length)); 984 | if (mb_strlen($full_title) > self::MAX_TITLE_LENGTH) { 985 | $split = preg_split('/\s+/u', utf8_strrev(mb_substr($full_title, 0, self::MAX_TITLE_LENGTH)), 2); 986 | $full_title = isset($split[1]) ? utf8_strrev($split[1]) : utf8_strrev($split[0]); 987 | 988 | $last_char = mb_substr($full_title, -1); 989 | if (in_array($last_char, array(',', ':', ';', '-'))) { 990 | $full_title = mb_substr($full_title, 0, -1) . '...'; 991 | } elseif (!in_array($last_char, array('.', '!', '?', ')'))) { 992 | $full_title .= '...'; 993 | } 994 | } 995 | $full_title = mb_strtoupper(mb_substr($full_title, 0, 1)) . mb_substr($full_title, 1); 996 | return $full_title; 997 | } 998 | 999 | protected function getPreviewAndOriginal($sizes) { 1000 | $photos = array(); 1001 | $typable = true; 1002 | array_walk($sizes, function(&$size_info, $k) use (&$photos, &$typable) { 1003 | $url = isset($size_info->url) ? $size_info->url : $size_info->src; 1004 | if (isset($size_info->type)) { 1005 | $photos[$size_info->type] = $url; 1006 | } else { 1007 | $typable = false; 1008 | $photos[$size_info->width] = $url; 1009 | } 1010 | }); 1011 | $huge_photo_src = null; 1012 | $preview_photo_src = null; 1013 | if ($typable) { 1014 | foreach (array('w', 'z', 'y', 'x', 'r', 'q', 'p', 'm', 'o', 's') as $type) { 1015 | if (array_key_exists($type, $photos)) { 1016 | $huge_photo_src = $photos[$type]; 1017 | break; 1018 | } 1019 | } 1020 | foreach (array('x', 'r', 'q', 'p', 'o', 'm', 's') as $type) { 1021 | if (array_key_exists($type, $photos)) { 1022 | $preview_photo_src = $photos[$type]; 1023 | break; 1024 | } 1025 | } 1026 | } else { 1027 | ksort($photos); 1028 | foreach ($photos as $width => $photo) { 1029 | if ($width > 800) { 1030 | break; 1031 | } 1032 | $preview_photo_src = $photo; 1033 | } 1034 | $huge_photo_src = end($photos); 1035 | } 1036 | return array($preview_photo_src, $huge_photo_src); 1037 | } 1038 | } 1039 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | $id); 27 | $config = array_merge($config, $_GET); 28 | $vk2rss = new Vk2rss($config); 29 | $vk2rss->generateRSS(); 30 | } catch (APIError $exc) { 31 | header("Content-type: text/plain; charset=utf-8"); 32 | $msg = "API Error {$exc->getApiErrorCode()}: {$exc->getMessage()}. Request URL: {$exc->getRequestUrl()}" . PHP_EOL; 33 | header("HTTP/1.1 {$exc->getCode()} {$msg}"); 34 | error_log($msg); 35 | die($msg); 36 | } catch (Exception $exc) { 37 | header("Content-type: text/plain; charset=utf-8"); 38 | if ($exc->getCode() >= 400 && $exc->getCode() < 600) { 39 | header("HTTP/1.1 {$exc->getCode()} {$exc->getMessage()}"); 40 | } else { 41 | header("HTTP/1.1 500 Internal Server Error"); 42 | } 43 | error_log($exc); 44 | die("Error: {$exc->getMessage()}"); 45 | } 46 | -------------------------------------------------------------------------------- /utils.php: -------------------------------------------------------------------------------- 1 | [^:]+?):\/\/)?(?:(?[^\/:]+):(?[^\/@]+)@)?(?P
[^\/@]+?)\/?$/', 68 | $address, $match); 69 | if (!empty($match['type'])) { 70 | $match['type'] = mb_strtolower($match['type']); 71 | if (!empty($type) && $match['type'] !== $type) { 72 | throw new Exception("Proxy type is passed multiple times (as part of address and as separate type) and these types are different"); 73 | } 74 | $type = $match['type']; 75 | } 76 | if (empty($type)) { 77 | $type = 'http'; 78 | } 79 | if (!isset(self::$supportedTypes[$type])) { 80 | throw new Exception("Proxy type '{$type}' does not allowed or incorrect. Allowed types: " 81 | . implode(', ', array_keys(self::$supportedTypes))); 82 | } 83 | $this->type = $type; 84 | 85 | if (!empty($match['login'])) { 86 | if (!empty($login) && $match['login'] !== $login) { 87 | throw new Exception("Login for proxy is passed multiple times (as part of address and as separate login) and these logins are different"); 88 | } 89 | $login = $match['login']; 90 | } 91 | 92 | if (!empty($match['password'])) { 93 | if (!empty($password) && $match['password'] !== $password) { 94 | throw new Exception("Password for proxy is passed multiple times (as part of address and as separate password) and these passwords are different"); 95 | } 96 | $password = $match['password']; 97 | } 98 | 99 | if (empty($login) && !empty($password) || !empty($login) && empty($password)) { 100 | throw new Exception("Both login and password must be given or not simultaneously."); 101 | } 102 | if (!empty($login) && mb_strpos($login, ':') !== false) { 103 | throw new Exception("Login must not contain colon ':'."); 104 | } 105 | $this->login = $login; 106 | $this->password = $password; 107 | 108 | if (empty($match['address'])) { 109 | throw new Exception("Invalid proxy address: '{$address}'"); 110 | } 111 | $this->address = $match['address']; 112 | } 113 | 114 | /** 115 | * @return array Matches allowed proxy type as string to cURL opt proxy type if cURL extension is loaded 116 | * otherwise matches allowed proxy type to 'true' 117 | */ 118 | public static function getSupportedTypes() 119 | { 120 | return self::$supportedTypes; 121 | } 122 | 123 | public static function init() 124 | { 125 | self::$supportedTypes = array(); 126 | if (extension_loaded('curl')) { 127 | self::$supportedTypes['http'] = CURLPROXY_HTTP; 128 | if (extension_loaded('openssl')) { 129 | self::$supportedTypes['https'] = CURLPROXY_HTTP; 130 | } 131 | if (defined('CURLPROXY_SOCKS4')) { 132 | self::$supportedTypes['socks4'] = CURLPROXY_SOCKS4; 133 | } 134 | if (defined('CURLPROXY_SOCKS4A')) { 135 | self::$supportedTypes['socks4a'] = CURLPROXY_SOCKS4A; 136 | } 137 | if (defined('CURLPROXY_SOCKS5')) { 138 | self::$supportedTypes['socks5'] = CURLPROXY_SOCKS5; 139 | } 140 | } 141 | if (ini_get('allow_url_fopen') == 1) { 142 | self::$supportedTypes['http'] = true; 143 | if (extension_loaded('openssl')) { 144 | self::$supportedTypes['https'] = true; 145 | } 146 | } 147 | } 148 | 149 | /** 150 | * @return string Proxy address including port if it's presented 151 | */ 152 | public function getAddress() 153 | { 154 | return $this->address; 155 | } 156 | 157 | /** 158 | * @return string Proxy type 159 | */ 160 | public function getType() 161 | { 162 | return $this->type; 163 | } 164 | 165 | /** 166 | * @return string|null Login for identification on proxy 167 | */ 168 | public function getLogin() 169 | { 170 | return $this->login; 171 | } 172 | 173 | /** 174 | * @return string|null Password for authentication on proxy 175 | */ 176 | public function getPassword() 177 | { 178 | return $this->password; 179 | } 180 | } 181 | ProxyDescriptor::init(); 182 | 183 | 184 | class ConnectionWrapper 185 | { 186 | /** 187 | * The number of seconds to wait while trying to connect 188 | */ 189 | const CONNECT_TIMEOUT = 10; 190 | /** 191 | * States to use file_get_contents function to download data 192 | */ 193 | const BUILTIN_DOWNLOADER = 1; 194 | /** 195 | * States to use cURL library to download data 196 | */ 197 | const CURL_DOWNLOADER = 2; 198 | /** 199 | * @var int way to download data 200 | */ 201 | protected $downloader; 202 | 203 | /** 204 | * @var resource Stream context resource 205 | */ 206 | protected $context; 207 | /** 208 | * @var array options for a cURL transfer 209 | */ 210 | protected $curlOpts; 211 | /** 212 | * @var resource cURL resource 213 | */ 214 | protected $curlHandler; 215 | 216 | /** 217 | * @var bool Whether the HTTPS protocol to be enabled for requests 218 | */ 219 | protected $httpsAllowed; 220 | 221 | /** 222 | * @var string|null Type of proxy in lowercase: http or https 223 | */ 224 | protected $proxyType = null; 225 | 226 | /** 227 | * @var bool Whether the connection to be closed 228 | */ 229 | protected $connectionIsClosed; 230 | 231 | /** 232 | * @var string|null URL of the last sent request 233 | */ 234 | protected $lastUrl = null; 235 | 236 | protected $nRequests = 0; 237 | 238 | /** 239 | * ConnectionWrapper constructor. 240 | * 241 | * @param ProxyDescriptor|null $proxy Proxy descriptor 242 | * @throws Exception If PHP configuration does not allow to use file_get_contents or cURL to download remote data 243 | */ 244 | public function __construct($proxy = null) 245 | { 246 | $supported_proxy_types = isset($proxy) ? ProxyDescriptor::getSupportedTypes() : null; 247 | 248 | if (ini_get('allow_url_fopen') == 1 249 | && (!isset($supported_proxy_types) || $supported_proxy_types[$proxy->getType()] === true)) { 250 | $this->downloader = self::BUILTIN_DOWNLOADER; 251 | } elseif (extension_loaded('curl')) { 252 | $this->downloader = self::CURL_DOWNLOADER; 253 | } else { 254 | throw new Exception('PHP configuration does not allow to use either file_get_contents ' . 255 | 'or cURL to download remote data, or chosen proxy type requires non-installed cURL extension', 500); 256 | } 257 | 258 | switch ($this->downloader) { 259 | case self::BUILTIN_DOWNLOADER: 260 | $opts = array(); 261 | $opts['http']['timeout'] = self::CONNECT_TIMEOUT; 262 | if (isset($proxy)) { 263 | $this->proxyType = $proxy->getType(); 264 | $address = $proxy->getAddress(); 265 | $opts['http']['proxy'] = "tcp://{$address}"; 266 | $opts['http']['request_fulluri'] = true; 267 | $login = $proxy->getLogin(); 268 | if (isset($login)) { 269 | $password = $proxy->getPassword(); 270 | $login_pass = base64_encode("{$login}:{$password}"); 271 | $opts['http']['header'] = "Proxy-Authorization: Basic {$login_pass}\r\nAuthorization: Basic {$login_pass}"; 272 | } 273 | } 274 | $this->context = stream_context_create($opts); 275 | break; 276 | case self::CURL_DOWNLOADER: 277 | $this->curlOpts = array(CURLOPT_RETURNTRANSFER => true, 278 | CURLOPT_HEADER => true, 279 | CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT); 280 | if (isset($proxy)) { 281 | $this->curlOpts[CURLOPT_PROXY] = $proxy->getAddress(); 282 | $this->proxyType = $proxy->getType(); 283 | $this->curlOpts[CURLOPT_PROXYTYPE] = $supported_proxy_types[$this->proxyType]; 284 | $login = $proxy->getLogin(); 285 | if (isset($login)) { 286 | $password = $proxy->getPassword(); 287 | $this->curlOpts[CURLOPT_USERPWD] = "{$login}:{$password}"; 288 | $this->curlOpts[CURLOPT_PROXYUSERPWD] = "{$login}:{$password}"; 289 | } 290 | } 291 | break; 292 | } 293 | 294 | $this->httpsAllowed = (isset($this->proxyType) && $this->proxyType === 'http') ? false : extension_loaded('openssl'); 295 | $this->connectionIsClosed = true; 296 | } 297 | 298 | public function __destruct() 299 | { 300 | $this->closeConnection(); 301 | } 302 | 303 | /** 304 | * Change connector state to be ready to retrieve content 305 | */ 306 | public function openConnection() 307 | { 308 | switch ($this->downloader) { 309 | case self::BUILTIN_DOWNLOADER: 310 | break; 311 | case self::CURL_DOWNLOADER: 312 | $this->curlHandler = curl_init(); 313 | curl_setopt_array($this->curlHandler, $this->curlOpts); 314 | break; 315 | } 316 | $this->connectionIsClosed = false; 317 | } 318 | 319 | /** 320 | * Close opened session and free resources 321 | */ 322 | public function closeConnection() 323 | { 324 | if (!$this->connectionIsClosed) { 325 | switch ($this->downloader) { 326 | case self::BUILTIN_DOWNLOADER: 327 | break; 328 | case self::CURL_DOWNLOADER: 329 | curl_close($this->curlHandler); 330 | break; 331 | } 332 | $this->connectionIsClosed = true; 333 | } 334 | } 335 | 336 | /** 337 | * Retrieve content from given URL. 338 | * 339 | * @param string|null $url URL 340 | * @param string|null $https_url URL with HTTPS protocol. If it's null then it's equal to $url where HTTP is replaced with HTTPS 341 | * @param bool $http_to_https Whether to use HTTP URL with replacing its HTTP protocol on HTTPS protocol 342 | * @return mixed Response body or FALSE on failure 343 | * @throws Exception If HTTPS url is passed and PHP or its extension does not support OpenSSL 344 | */ 345 | public function getContent($url, $https_url = null, $http_to_https = false) 346 | { 347 | if ($this->httpsAllowed && (!empty($https_url) || $http_to_https)) { 348 | $request_url = empty($https_url) ? preg_replace('/^http:/ui', 'https:', $url) : $https_url; 349 | } else { 350 | if (mb_substr($url, 0, 5) === "https") { 351 | throw new Exception("Cannot send request through HTTPS protocol. " 352 | . "Only HTTP protocol is allowed by configuration and your arguments", 400); 353 | } 354 | $request_url = $url; 355 | } 356 | $this->lastUrl = $request_url; 357 | if ($this->nRequests && $this->nRequests % 2 == 0) { 358 | usleep(1100000); 359 | } 360 | switch ($this->downloader) { 361 | case self::BUILTIN_DOWNLOADER: 362 | $this->nRequests += 1; 363 | $body = @file_get_contents($request_url, false, $this->context); 364 | $response_code = isset($http_response_header) ? (int)substr($http_response_header[0], 9, 3) : null; 365 | if (empty($body)) { 366 | $error_msg = error_get_last(); 367 | throw new Exception("Cannot retrieve data from URL '{$request_url}'" 368 | . (isset($error_msg) ? ": {$error_msg['message']}" : ''), 369 | (!empty($response_code) && $response_code != 200) ? $response_code : 500); 370 | } 371 | if ($response_code != 200) { 372 | throw new Exception($body, $response_code); 373 | } 374 | break; 375 | case self::CURL_DOWNLOADER: 376 | $this->nRequests += 1; 377 | curl_setopt($this->curlHandler, CURLOPT_URL, $request_url); 378 | $response = curl_exec($this->curlHandler); 379 | if (empty($response)) { 380 | $response_code = curl_getinfo($this->curlHandler, CURLINFO_HTTP_CODE); 381 | throw new Exception("Cannot retrieve data from URL '{$request_url}': " 382 | . curl_error($this->curlHandler), 383 | in_array($response_code, array(0, 200)) ? 500 : $response_code); 384 | } 385 | $split_response = explode("\r\n\r\n", $response, 3); 386 | if (isset($split_response[2])) { 387 | $header = $split_response[1]; 388 | $body = $split_response[2]; 389 | } else { 390 | $header = $split_response[0]; 391 | $body = $split_response[1]; 392 | } 393 | if (empty($body)) { 394 | throw new Exception("Cannot retrieve data from URL '{$request_url}': empty body", 395 | 503); 396 | } 397 | list($header, ) = explode("\r\n", $header, 2); 398 | $response_code = (int)substr($header, 9, 3); 399 | if ($response_code != 200) { 400 | throw new Exception("Cannot retrieve data from URL '{$request_url}': " . substr($header, 13) 401 | . ": [" . curl_errno($this->curlHandler) . "] ". curl_error($this->curlHandler), 402 | $response_code == 0 ? 500 : $response_code); 403 | } 404 | break; 405 | default: 406 | throw new ErrorException("", 500); 407 | } 408 | return $body; 409 | } 410 | 411 | /** 412 | * @return string|null The last retrieved URL 413 | */ 414 | public function getLastUrl() 415 | { 416 | return $this->lastUrl; 417 | } 418 | } 419 | 420 | 421 | class APIError extends Exception { 422 | protected $apiErrorCode; 423 | protected $requestUrl; 424 | 425 | /** 426 | * APIError constructor. 427 | * 428 | * @param object $error_response 429 | * @param string $request_url 430 | */ 431 | public function __construct($error_response, $request_url) 432 | { 433 | $message = $error_response->error->error_msg; 434 | switch ($error_response->error->error_code) { 435 | case 5: 436 | if (mb_strpos($message, "invalid session") !== false) { 437 | $message = "Access token is expired (probably by app session terminating). It is necessary to create new token. {$message}"; 438 | } 439 | break; 440 | case 17: 441 | $message .= ": {$error_response->error->redirect_uri}"; 442 | break; 443 | } 444 | parent::__construct($message, 400); 445 | $this->apiErrorCode = $error_response->error->error_code; 446 | $this->requestUrl = $request_url; 447 | } 448 | 449 | /** 450 | * @return int API error code 451 | */ 452 | public function getApiErrorCode() 453 | { 454 | return $this->apiErrorCode; 455 | } 456 | 457 | /** 458 | * @return string Requested API URL 459 | */ 460 | public function getRequestUrl() 461 | { 462 | return $this->requestUrl; 463 | } 464 | } 465 | --------------------------------------------------------------------------------