]> git.mxchange.org Git - friendica.git/blob - src/Protocol/Feed.php
Merge branch '2020.06-rc' into stable
[friendica.git] / src / Protocol / Feed.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  */
21
22 namespace Friendica\Protocol;
23
24 use DOMDocument;
25 use DOMXPath;
26 use Friendica\Content\Text\HTML;
27 use Friendica\Core\Logger;
28 use Friendica\Core\Protocol;
29 use Friendica\Database\DBA;
30 use Friendica\DI;
31 use Friendica\Model\Item;
32 use Friendica\Model\Tag;
33 use Friendica\Util\Network;
34 use Friendica\Util\ParseUrl;
35 use Friendica\Util\XML;
36
37 /**
38  * This class contain functions to import feeds (RSS/RDF/Atom)
39  */
40 class Feed
41 {
42         /**
43          * consume - process atom feed and update anything/everything we might need to update
44          *
45          * $xml = the (atom) feed to consume - RSS isn't as fully supported but may work for simple feeds.
46          *
47          * $importer = the contact_record (joined to user_record) of the local user who owns this relationship.
48          *             It is this person's stuff that is going to be updated.
49          * $contact =  the person who is sending us stuff. If not set, we MAY be processing a "follow" activity
50          *             from an external network and MAY create an appropriate contact record. Otherwise, we MUST
51          *             have a contact record.
52          * $hub = should we find a hub declation in the feed, pass it back to our calling process, who might (or
53          *        might not) try and subscribe to it.
54          * $datedir sorts in reverse order
55          * $pass - by default ($pass = 0) we cannot guarantee that a parent item has been
56          *      imported prior to its children being seen in the stream unless we are certain
57          *      of how the feed is arranged/ordered.
58          * With $pass = 1, we only pull parent items out of the stream.
59          * With $pass = 2, we only pull children (comments/likes).
60          *
61          * So running this twice, first with pass 1 and then with pass 2 will do the right
62          * thing regardless of feed ordering. This won't be adequate in a fully-threaded
63          * model where comments can have sub-threads. That would require some massive sorting
64          * to get all the feed items into a mostly linear ordering, and might still require
65          * recursion.
66          *
67          * @param       $xml
68          * @param array $importer
69          * @param array $contact
70          * @param       $hub
71          * @throws ImagickException
72          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
73          */
74         public static function consume($xml, array $importer, array $contact, &$hub)
75         {
76                 if ($contact['network'] === Protocol::OSTATUS) {
77                         Logger::info('Consume OStatus messages');
78                         OStatus::import($xml, $importer, $contact, $hub);
79
80                         return;
81                 }
82
83                 if ($contact['network'] === Protocol::FEED) {
84                         Logger::info('Consume feeds');
85                         self::import($xml, $importer, $contact);
86
87                         return;
88                 }
89
90                 if ($contact['network'] === Protocol::DFRN) {
91                         Logger::info('Consume DFRN messages');
92                         $dfrn_importer = DFRN::getImporter($contact['id'], $importer['uid']);
93                         if (!empty($dfrn_importer)) {
94                                 Logger::info('Now import the DFRN feed');
95                                 DFRN::import($xml, $dfrn_importer, true);
96                                 return;
97                         }
98                 }
99         }
100
101         /**
102          * Read a RSS/RDF/Atom feed and create an item entry for it
103          *
104          * @param string $xml      The feed data
105          * @param array  $importer The user record of the importer
106          * @param array  $contact  The contact record of the feed
107          *
108          * @return array Returns the header and the first item in dry run mode
109          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
110          */
111         public static function import($xml, array $importer = [], array $contact = [])
112         {
113                 $dryRun = empty($importer) && empty($contact);
114
115                 if ($dryRun) {
116                         Logger::info("Test Atom/RSS feed");
117                 } else {
118                         Logger::info("Import Atom/RSS feed '" . $contact["name"] . "' (Contact " . $contact["id"] . ") for user " . $importer["uid"]);
119                 }
120
121                 if (empty($xml)) {
122                         Logger::info('XML is empty.');
123                         return [];
124                 }
125
126                 if (!empty($contact['poll'])) {
127                         $basepath = $contact['poll'];
128                 } elseif (!empty($contact['url'])) {
129                         $basepath = $contact['url'];
130                 } else {
131                         $basepath = '';
132                 }
133
134                 $doc = new DOMDocument();
135                 @$doc->loadXML(trim($xml));
136                 $xpath = new DOMXPath($doc);
137                 $xpath->registerNamespace('atom', ActivityNamespace::ATOM1);
138                 $xpath->registerNamespace('dc', "http://purl.org/dc/elements/1.1/");
139                 $xpath->registerNamespace('content', "http://purl.org/rss/1.0/modules/content/");
140                 $xpath->registerNamespace('rdf', "http://www.w3.org/1999/02/22-rdf-syntax-ns#");
141                 $xpath->registerNamespace('rss', "http://purl.org/rss/1.0/");
142                 $xpath->registerNamespace('media', "http://search.yahoo.com/mrss/");
143                 $xpath->registerNamespace('poco', ActivityNamespace::POCO);
144
145                 $author = [];
146                 $entries = null;
147
148                 // Is it RDF?
149                 if ($xpath->query('/rdf:RDF/rss:channel')->length > 0) {
150                         $author["author-link"] = XML::getFirstNodeValue($xpath, '/rdf:RDF/rss:channel/rss:link/text()');
151                         $author["author-name"] = XML::getFirstNodeValue($xpath, '/rdf:RDF/rss:channel/rss:title/text()');
152
153                         if (empty($author["author-name"])) {
154                                 $author["author-name"] = XML::getFirstNodeValue($xpath, '/rdf:RDF/rss:channel/rss:description/text()');
155                         }
156                         $entries = $xpath->query('/rdf:RDF/rss:item');
157                 }
158
159                 // Is it Atom?
160                 if ($xpath->query('/atom:feed')->length > 0) {
161                         $alternate = XML::getFirstAttributes($xpath, "atom:link[@rel='alternate']");
162                         if (is_object($alternate)) {
163                                 foreach ($alternate AS $attribute) {
164                                         if ($attribute->name == "href") {
165                                                 $author["author-link"] = $attribute->textContent;
166                                         }
167                                 }
168                         }
169
170                         if (empty($author["author-link"])) {
171                                 $self = XML::getFirstAttributes($xpath, "atom:link[@rel='self']");
172                                 if (is_object($self)) {
173                                         foreach ($self AS $attribute) {
174                                                 if ($attribute->name == "href") {
175                                                         $author["author-link"] = $attribute->textContent;
176                                                 }
177                                         }
178                                 }
179                         }
180
181                         if (empty($author["author-link"])) {
182                                 $author["author-link"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:id/text()');
183                         }
184                         $author["author-avatar"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:logo/text()');
185
186                         $author["author-name"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:title/text()');
187
188                         if (empty($author["author-name"])) {
189                                 $author["author-name"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:subtitle/text()');
190                         }
191
192                         if (empty($author["author-name"])) {
193                                 $author["author-name"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:author/atom:name/text()');
194                         }
195
196                         $value = XML::getFirstNodeValue($xpath, 'atom:author/poco:displayName/text()');
197                         if ($value != "") {
198                                 $author["author-name"] = $value;
199                         }
200
201                         if ($dryRun) {
202                                 $author["author-id"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:author/atom:id/text()');
203
204                                 // See https://tools.ietf.org/html/rfc4287#section-3.2.2
205                                 $value = XML::getFirstNodeValue($xpath, 'atom:author/atom:uri/text()');
206                                 if ($value != "") {
207                                         $author["author-link"] = $value;
208                                 }
209
210                                 $value = XML::getFirstNodeValue($xpath, 'atom:author/poco:preferredUsername/text()');
211                                 if ($value != "") {
212                                         $author["author-nick"] = $value;
213                                 }
214
215                                 $value = XML::getFirstNodeValue($xpath, 'atom:author/poco:address/poco:formatted/text()');
216                                 if ($value != "") {
217                                         $author["author-location"] = $value;
218                                 }
219
220                                 $value = XML::getFirstNodeValue($xpath, 'atom:author/poco:note/text()');
221                                 if ($value != "") {
222                                         $author["author-about"] = $value;
223                                 }
224
225                                 $avatar = XML::getFirstAttributes($xpath, "atom:author/atom:link[@rel='avatar']");
226                                 if (is_object($avatar)) {
227                                         foreach ($avatar AS $attribute) {
228                                                 if ($attribute->name == "href") {
229                                                         $author["author-avatar"] = $attribute->textContent;
230                                                 }
231                                         }
232                                 }
233                         }
234
235                         $author["edited"] = $author["created"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:updated/text()');
236
237                         $author["app"] = XML::getFirstNodeValue($xpath, '/atom:feed/atom:generator/text()');
238
239                         $entries = $xpath->query('/atom:feed/atom:entry');
240                 }
241
242                 // Is it RSS?
243                 if ($xpath->query('/rss/channel')->length > 0) {
244                         $author["author-link"] = XML::getFirstNodeValue($xpath, '/rss/channel/link/text()');
245
246                         $author["author-name"] = XML::getFirstNodeValue($xpath, '/rss/channel/title/text()');
247                         $author["author-avatar"] = XML::getFirstNodeValue($xpath, '/rss/channel/image/url/text()');
248
249                         if (empty($author["author-name"])) {
250                                 $author["author-name"] = XML::getFirstNodeValue($xpath, '/rss/channel/copyright/text()');
251                         }
252
253                         if (empty($author["author-name"])) {
254                                 $author["author-name"] = XML::getFirstNodeValue($xpath, '/rss/channel/description/text()');
255                         }
256
257                         $author["edited"] = $author["created"] = XML::getFirstNodeValue($xpath, '/rss/channel/pubDate/text()');
258
259                         $author["app"] = XML::getFirstNodeValue($xpath, '/rss/channel/generator/text()');
260
261                         $entries = $xpath->query('/rss/channel/item');
262                 }
263
264                 if (!$dryRun) {
265                         $author["author-link"] = $contact["url"];
266
267                         if (empty($author["author-name"])) {
268                                 $author["author-name"] = $contact["name"];
269                         }
270
271                         $author["author-avatar"] = $contact["thumb"];
272
273                         $author["owner-link"] = $contact["url"];
274                         $author["owner-name"] = $contact["name"];
275                         $author["owner-avatar"] = $contact["thumb"];
276                 }
277
278                 $header = [];
279                 $header["uid"] = $importer["uid"] ?? 0;
280                 $header["network"] = Protocol::FEED;
281                 $header["wall"] = 0;
282                 $header["origin"] = 0;
283                 $header["gravity"] = GRAVITY_PARENT;
284                 $header["private"] = Item::PUBLIC;
285                 $header["verb"] = Activity::POST;
286                 $header["object-type"] = Activity\ObjectType::NOTE;
287
288                 $header["contact-id"] = $contact["id"] ?? 0;
289
290                 if (!is_object($entries)) {
291                         Logger::info("There are no entries in this feed.");
292                         return [];
293                 }
294
295                 $items = [];
296
297                 // Limit the number of items that are about to be fetched
298                 $total_items = ($entries->length - 1);
299                 $max_items = DI::config()->get('system', 'max_feed_items');
300                 if (($max_items > 0) && ($total_items > $max_items)) {
301                         $total_items = $max_items;
302                 }
303
304                 // Importing older entries first
305                 for ($i = $total_items; $i >= 0; --$i) {
306                         $entry = $entries->item($i);
307
308                         $item = array_merge($header, $author);
309
310                         $alternate = XML::getFirstAttributes($xpath, "atom:link[@rel='alternate']", $entry);
311                         if (!is_object($alternate)) {
312                                 $alternate = XML::getFirstAttributes($xpath, "atom:link", $entry);
313                         }
314                         if (is_object($alternate)) {
315                                 foreach ($alternate AS $attribute) {
316                                         if ($attribute->name == "href") {
317                                                 $item["plink"] = $attribute->textContent;
318                                         }
319                                 }
320                         }
321
322                         if (empty($item["plink"])) {
323                                 $item["plink"] = XML::getFirstNodeValue($xpath, 'link/text()', $entry);
324                         }
325
326                         if (empty($item["plink"])) {
327                                 $item["plink"] = XML::getFirstNodeValue($xpath, 'rss:link/text()', $entry);
328                         }
329
330                         $item["uri"] = XML::getFirstNodeValue($xpath, 'atom:id/text()', $entry);
331
332                         if (empty($item["uri"])) {
333                                 $item["uri"] = XML::getFirstNodeValue($xpath, 'guid/text()', $entry);
334                         }
335
336                         if (empty($item["uri"])) {
337                                 $item["uri"] = $item["plink"];
338                         }
339
340                         // Add the base path if missing
341                         $item["uri"] = Network::addBasePath($item["uri"], $basepath);
342                         $item["plink"] = Network::addBasePath($item["plink"], $basepath);
343
344                         $orig_plink = $item["plink"];
345
346                         $item["plink"] = Network::finalUrl($item["plink"]);
347
348                         $item["parent-uri"] = $item["uri"];
349
350                         if (!$dryRun) {
351                                 $condition = ["`uid` = ? AND `uri` = ? AND `network` IN (?, ?)",
352                                         $importer["uid"], $item["uri"], Protocol::FEED, Protocol::DFRN];
353                                 $previous = Item::selectFirst(['id'], $condition);
354                                 if (DBA::isResult($previous)) {
355                                         Logger::info("Item with uri " . $item["uri"] . " for user " . $importer["uid"] . " already existed under id " . $previous["id"]);
356                                         continue;
357                                 }
358                         }
359
360                         $item["title"] = XML::getFirstNodeValue($xpath, 'atom:title/text()', $entry);
361
362                         if (empty($item["title"])) {
363                                 $item["title"] = XML::getFirstNodeValue($xpath, 'title/text()', $entry);
364                         }
365                         if (empty($item["title"])) {
366                                 $item["title"] = XML::getFirstNodeValue($xpath, 'rss:title/text()', $entry);
367                         }
368
369                         $item["title"] = html_entity_decode($item["title"], ENT_QUOTES, 'UTF-8');
370
371                         $published = XML::getFirstNodeValue($xpath, 'atom:published/text()', $entry);
372
373                         if (empty($published)) {
374                                 $published = XML::getFirstNodeValue($xpath, 'pubDate/text()', $entry);
375                         }
376
377                         if (empty($published)) {
378                                 $published = XML::getFirstNodeValue($xpath, 'dc:date/text()', $entry);
379                         }
380
381                         $updated = XML::getFirstNodeValue($xpath, 'atom:updated/text()', $entry);
382
383                         if (empty($updated) && !empty($published)) {
384                                 $updated = $published;
385                         }
386
387                         if (empty($published) && !empty($updated)) {
388                                 $published = $updated;
389                         }
390
391                         if ($published != "") {
392                                 $item["created"] = $published;
393                         }
394
395                         if ($updated != "") {
396                                 $item["edited"] = $updated;
397                         }
398
399                         $creator = XML::getFirstNodeValue($xpath, 'author/text()', $entry);
400
401                         if (empty($creator)) {
402                                 $creator = XML::getFirstNodeValue($xpath, 'atom:author/atom:name/text()', $entry);
403                         }
404
405                         if (empty($creator)) {
406                                 $creator = XML::getFirstNodeValue($xpath, 'dc:creator/text()', $entry);
407                         }
408
409                         if ($creator != "") {
410                                 $item["author-name"] = $creator;
411                         }
412
413                         $creator = XML::getFirstNodeValue($xpath, 'dc:creator/text()', $entry);
414
415                         if ($creator != "") {
416                                 $item["author-name"] = $creator;
417                         }
418
419                         /// @TODO ?
420                         // <category>Ausland</category>
421                         // <media:thumbnail width="152" height="76" url="http://www.taz.de/picture/667875/192/14388767.jpg"/>
422
423                         $attachments = [];
424
425                         $enclosures = $xpath->query("enclosure|atom:link[@rel='enclosure']", $entry);
426                         foreach ($enclosures AS $enclosure) {
427                                 $href = "";
428                                 $length = "";
429                                 $type = "";
430
431                                 foreach ($enclosure->attributes AS $attribute) {
432                                         if (in_array($attribute->name, ["url", "href"])) {
433                                                 $href = $attribute->textContent;
434                                         } elseif ($attribute->name == "length") {
435                                                 $length = $attribute->textContent;
436                                         } elseif ($attribute->name == "type") {
437                                                 $type = $attribute->textContent;
438                                         }
439                                 }
440
441                                 if (!empty($item["attach"])) {
442                                         $item["attach"] .= ',';
443                                 } else {
444                                         $item["attach"] = '';
445                                 }
446
447                                 $attachments[] = ["link" => $href, "type" => $type, "length" => $length];
448
449                                 $item["attach"] .= '[attach]href="' . $href . '" length="' . $length . '" type="' . $type . '"[/attach]';
450                         }
451
452                         $taglist = [];
453                         $categories = $xpath->query("category", $entry);
454                         foreach ($categories AS $category) {
455                                 $taglist[] = $category->nodeValue;
456                         }
457
458                         $body = trim(XML::getFirstNodeValue($xpath, 'atom:content/text()', $entry));
459
460                         if (empty($body)) {
461                                 $body = trim(XML::getFirstNodeValue($xpath, 'content:encoded/text()', $entry));
462                         }
463
464                         $summary = trim(XML::getFirstNodeValue($xpath, 'atom:summary/text()', $entry));
465
466                         if (empty($summary)) {
467                                 $summary = trim(XML::getFirstNodeValue($xpath, 'description/text()', $entry));
468                         }
469
470                         if (empty($body)) {
471                                 $body = $summary;
472                                 $summary = '';
473                         }
474
475                         if ($body == $summary) {
476                                 $summary = '';
477                         }
478
479                         // remove the content of the title if it is identically to the body
480                         // This helps with auto generated titles e.g. from tumblr
481                         if (self::titleIsBody($item["title"], $body)) {
482                                 $item["title"] = "";
483                         }
484                         $item["body"] = HTML::toBBCode($body, $basepath);
485
486                         if (($item["body"] == '') && ($item["title"] != '')) {
487                                 $item["body"] = $item["title"];
488                                 $item["title"] = '';
489                         }
490
491                         $preview = '';
492                         if (!empty($contact["fetch_further_information"]) && ($contact["fetch_further_information"] < 3)) {
493                                 // Handle enclosures and treat them as preview picture
494                                 foreach ($attachments AS $attachment) {
495                                         if ($attachment["type"] == "image/jpeg") {
496                                                 $preview = $attachment["link"];
497                                         }
498                                 }
499
500                                 // Remove a possible link to the item itself
501                                 $item["body"] = str_replace($item["plink"], '', $item["body"]);
502                                 $item["body"] = trim(preg_replace('/\[url\=\](\w+.*?)\[\/url\]/i', '', $item["body"]));
503
504                                 // Replace the content when the title is longer than the body
505                                 $replace = (strlen($item["title"]) > strlen($item["body"]));
506
507                                 // Replace it, when there is an image in the body
508                                 if (strstr($item["body"], '[/img]')) {
509                                         $replace = true;
510                                 }
511
512                                 // Replace it, when there is a link in the body
513                                 if (strstr($item["body"], '[/url]')) {
514                                         $replace = true;
515                                 }
516
517                                 if ($replace) {
518                                         $item["body"] = trim($item["title"]);
519                                 }
520
521                                 $data = ParseUrl::getSiteinfoCached($item['plink'], true);
522                                 if (!empty($data['text']) && !empty($data['title']) && (mb_strlen($item['body']) < mb_strlen($data['text']))) {
523                                         // When the fetched page info text is longer than the body, we do try to enhance the body
524                                         if (!empty($item['body']) && (strpos($data['title'], $item['body']) === false) && (strpos($data['text'], $item['body']) === false)) {
525                                                 // The body is not part of the fetched page info title or page info text. So we add the text to the body
526                                                 $item['body'] .= "\n\n" . $data['text'];
527                                         } else {
528                                                 // Else we replace the body with the page info text
529                                                 $item['body'] = $data['text'];
530                                         }
531                                 }
532
533                                 // We always strip the title since it will be added in the page information
534                                 $item["title"] = "";
535                                 $item["body"] = $item["body"] . add_page_info($item["plink"], false, $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_denylist"] ?? '');
536                                 $taglist = get_page_keywords($item["plink"], $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_denylist"]);
537                                 $item["object-type"] = Activity\ObjectType::BOOKMARK;
538                                 unset($item["attach"]);
539                         } else {
540                                 if (!empty($summary)) {
541                                         $item["body"] = '[abstract]' . HTML::toBBCode($summary, $basepath) . "[/abstract]\n" . $item["body"];
542                                 }
543
544                                 if (!empty($contact["fetch_further_information"]) && ($contact["fetch_further_information"] == 3)) {
545                                         if (empty($taglist)) {
546                                                 $taglist = get_page_keywords($item["plink"], $preview, true, $contact["ffi_keyword_denylist"]);
547                                         }
548                                         $item["body"] .= "\n" . self::tagToString($taglist);
549                                 } else {
550                                         $taglist = [];
551                                 }
552
553                                 // Add the link to the original feed entry if not present in feed
554                                 if (($item['plink'] != '') && !strstr($item["body"], $item['plink'])) {
555                                         $item["body"] .= "[hr][url]" . $item['plink'] . "[/url]";
556                                 }
557                         }
558
559                         if ($dryRun) {
560                                 $items[] = $item;
561                                 break;
562                         } else {
563                                 Logger::info('Stored feed', ['item' => $item]);
564
565                                 $notify = Item::isRemoteSelf($contact, $item);
566
567                                 // Distributed items should have a well formatted URI.
568                                 // Additionally we have to avoid conflicts with identical URI between imported feeds and these items.
569                                 if ($notify) {
570                                         $item['guid'] = Item::guidFromUri($orig_plink, DI::baseUrl()->getHostname());
571                                         unset($item['uri']);
572                                         unset($item['parent-uri']);
573
574                                         // Set the delivery priority for "remote self" to "medium"
575                                         $notify = PRIORITY_MEDIUM;
576                                 }
577
578                                 $id = Item::insert($item, $notify);
579
580                                 Logger::info("Feed for contact " . $contact["url"] . " stored under id " . $id);
581
582                                 if (!empty($id) && !empty($taglist)) {
583                                         $feeditem = Item::selectFirst(['uri-id'], ['id' => $id]);
584                                         foreach ($taglist as $tag) {
585                                                 Tag::store($feeditem['uri-id'], Tag::HASHTAG, $tag);
586                                         }                                       
587                                 }
588                         }
589                 }
590
591                 return ["header" => $author, "items" => $items];
592         }
593
594         /**
595          * Convert a tag array to a tag string
596          *
597          * @param array $tags
598          * @return string tag string
599          */
600         private static function tagToString(array $tags)
601         {
602                 $tagstr = '';
603
604                 foreach ($tags as $tag) {
605                         if ($tagstr != "") {
606                                 $tagstr .= ", ";
607                         }
608         
609                         $tagstr .= "#[url=" . DI::baseUrl() . "/search?tag=" . urlencode($tag) . "]" . $tag . "[/url]";
610                 }
611
612                 return $tagstr;
613         }
614
615         private static function titleIsBody($title, $body)
616         {
617                 $title = strip_tags($title);
618                 $title = trim($title);
619                 $title = html_entity_decode($title, ENT_QUOTES, 'UTF-8');
620                 $title = str_replace(["\n", "\r", "\t", " "], ["", "", "", ""], $title);
621
622                 $body = strip_tags($body);
623                 $body = trim($body);
624                 $body = html_entity_decode($body, ENT_QUOTES, 'UTF-8');
625                 $body = str_replace(["\n", "\r", "\t", " "], ["", "", "", ""], $body);
626
627                 if (strlen($title) < strlen($body)) {
628                         $body = substr($body, 0, strlen($title));
629                 }
630
631                 if (($title != $body) && (substr($title, -3) == "...")) {
632                         $pos = strrpos($title, "...");
633                         if ($pos > 0) {
634                                 $title = substr($title, 0, $pos);
635                                 $body = substr($body, 0, $pos);
636                         }
637                 }
638                 return ($title == $body);
639         }
640 }