├── .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)) ? "]]>$tagName>" : "$tagName>";
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 |
--------------------------------------------------------------------------------