]> git.mxchange.org Git - friendica.git/blob - src/Protocol/Feed.php
Merge pull request #8812 from tobiasd/2020.06-credits
[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                         $orig_plink = $item["plink"];
341
342                         $item["plink"] = Network::finalUrl($item["plink"]);
343
344                         $item["parent-uri"] = $item["uri"];
345
346                         if (!$dryRun) {
347                                 $condition = ["`uid` = ? AND `uri` = ? AND `network` IN (?, ?)",
348                                         $importer["uid"], $item["uri"], Protocol::FEED, Protocol::DFRN];
349                                 $previous = Item::selectFirst(['id'], $condition);
350                                 if (DBA::isResult($previous)) {
351                                         Logger::info("Item with uri " . $item["uri"] . " for user " . $importer["uid"] . " already existed under id " . $previous["id"]);
352                                         continue;
353                                 }
354                         }
355
356                         $item["title"] = XML::getFirstNodeValue($xpath, 'atom:title/text()', $entry);
357
358                         if (empty($item["title"])) {
359                                 $item["title"] = XML::getFirstNodeValue($xpath, 'title/text()', $entry);
360                         }
361                         if (empty($item["title"])) {
362                                 $item["title"] = XML::getFirstNodeValue($xpath, 'rss:title/text()', $entry);
363                         }
364
365                         $item["title"] = html_entity_decode($item["title"], ENT_QUOTES, 'UTF-8');
366
367                         $published = XML::getFirstNodeValue($xpath, 'atom:published/text()', $entry);
368
369                         if (empty($published)) {
370                                 $published = XML::getFirstNodeValue($xpath, 'pubDate/text()', $entry);
371                         }
372
373                         if (empty($published)) {
374                                 $published = XML::getFirstNodeValue($xpath, 'dc:date/text()', $entry);
375                         }
376
377                         $updated = XML::getFirstNodeValue($xpath, 'atom:updated/text()', $entry);
378
379                         if (empty($updated) && !empty($published)) {
380                                 $updated = $published;
381                         }
382
383                         if (empty($published) && !empty($updated)) {
384                                 $published = $updated;
385                         }
386
387                         if ($published != "") {
388                                 $item["created"] = $published;
389                         }
390
391                         if ($updated != "") {
392                                 $item["edited"] = $updated;
393                         }
394
395                         $creator = XML::getFirstNodeValue($xpath, 'author/text()', $entry);
396
397                         if (empty($creator)) {
398                                 $creator = XML::getFirstNodeValue($xpath, 'atom:author/atom:name/text()', $entry);
399                         }
400
401                         if (empty($creator)) {
402                                 $creator = XML::getFirstNodeValue($xpath, 'dc:creator/text()', $entry);
403                         }
404
405                         if ($creator != "") {
406                                 $item["author-name"] = $creator;
407                         }
408
409                         $creator = XML::getFirstNodeValue($xpath, 'dc:creator/text()', $entry);
410
411                         if ($creator != "") {
412                                 $item["author-name"] = $creator;
413                         }
414
415                         /// @TODO ?
416                         // <category>Ausland</category>
417                         // <media:thumbnail width="152" height="76" url="http://www.taz.de/picture/667875/192/14388767.jpg"/>
418
419                         $attachments = [];
420
421                         $enclosures = $xpath->query("enclosure|atom:link[@rel='enclosure']", $entry);
422                         foreach ($enclosures AS $enclosure) {
423                                 $href = "";
424                                 $length = "";
425                                 $type = "";
426
427                                 foreach ($enclosure->attributes AS $attribute) {
428                                         if (in_array($attribute->name, ["url", "href"])) {
429                                                 $href = $attribute->textContent;
430                                         } elseif ($attribute->name == "length") {
431                                                 $length = $attribute->textContent;
432                                         } elseif ($attribute->name == "type") {
433                                                 $type = $attribute->textContent;
434                                         }
435                                 }
436
437                                 if (!empty($item["attach"])) {
438                                         $item["attach"] .= ',';
439                                 } else {
440                                         $item["attach"] = '';
441                                 }
442
443                                 $attachments[] = ["link" => $href, "type" => $type, "length" => $length];
444
445                                 $item["attach"] .= '[attach]href="' . $href . '" length="' . $length . '" type="' . $type . '"[/attach]';
446                         }
447
448                         $taglist = [];
449                         $categories = $xpath->query("category", $entry);
450                         foreach ($categories AS $category) {
451                                 $taglist[] = $category->nodeValue;
452                         }
453
454                         $body = trim(XML::getFirstNodeValue($xpath, 'atom:content/text()', $entry));
455
456                         if (empty($body)) {
457                                 $body = trim(XML::getFirstNodeValue($xpath, 'content:encoded/text()', $entry));
458                         }
459
460                         $summary = trim(XML::getFirstNodeValue($xpath, 'atom:summary/text()', $entry));
461
462                         if (empty($summary)) {
463                                 $summary = trim(XML::getFirstNodeValue($xpath, 'description/text()', $entry));
464                         }
465
466                         if (empty($body)) {
467                                 $body = $summary;
468                                 $summary = '';
469                         }
470
471                         if ($body == $summary) {
472                                 $summary = '';
473                         }
474
475                         // remove the content of the title if it is identically to the body
476                         // This helps with auto generated titles e.g. from tumblr
477                         if (self::titleIsBody($item["title"], $body)) {
478                                 $item["title"] = "";
479                         }
480                         $item["body"] = HTML::toBBCode($body, $basepath);
481
482                         if (($item["body"] == '') && ($item["title"] != '')) {
483                                 $item["body"] = $item["title"];
484                                 $item["title"] = '';
485                         }
486
487                         $preview = '';
488                         if (!empty($contact["fetch_further_information"]) && ($contact["fetch_further_information"] < 3)) {
489                                 // Handle enclosures and treat them as preview picture
490                                 foreach ($attachments AS $attachment) {
491                                         if ($attachment["type"] == "image/jpeg") {
492                                                 $preview = $attachment["link"];
493                                         }
494                                 }
495
496                                 // Remove a possible link to the item itself
497                                 $item["body"] = str_replace($item["plink"], '', $item["body"]);
498                                 $item["body"] = trim(preg_replace('/\[url\=\](\w+.*?)\[\/url\]/i', '', $item["body"]));
499
500                                 // Replace the content when the title is longer than the body
501                                 $replace = (strlen($item["title"]) > strlen($item["body"]));
502
503                                 // Replace it, when there is an image in the body
504                                 if (strstr($item["body"], '[/img]')) {
505                                         $replace = true;
506                                 }
507
508                                 // Replace it, when there is a link in the body
509                                 if (strstr($item["body"], '[/url]')) {
510                                         $replace = true;
511                                 }
512
513                                 if ($replace) {
514                                         $item["body"] = trim($item["title"]);
515                                 }
516
517                                 $data = ParseUrl::getSiteinfoCached($item['plink'], true);
518                                 if (!empty($data['text']) && !empty($data['title']) && (mb_strlen($item['body']) < mb_strlen($data['text']))) {
519                                         // When the fetched page info text is longer than the body, we do try to enhance the body
520                                         if (!empty($item['body']) && (strpos($data['title'], $item['body']) === false) && (strpos($data['text'], $item['body']) === false)) {
521                                                 // The body is not part of the fetched page info title or page info text. So we add the text to the body
522                                                 $item['body'] .= "\n\n" . $data['text'];
523                                         } else {
524                                                 // Else we replace the body with the page info text
525                                                 $item['body'] = $data['text'];
526                                         }
527                                 }
528
529                                 // We always strip the title since it will be added in the page information
530                                 $item["title"] = "";
531                                 $item["body"] = $item["body"] . add_page_info($item["plink"], false, $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_denylist"] ?? '');
532                                 $taglist = get_page_keywords($item["plink"], $preview, ($contact["fetch_further_information"] == 2), $contact["ffi_keyword_denylist"]);
533                                 $item["object-type"] = Activity\ObjectType::BOOKMARK;
534                                 unset($item["attach"]);
535                         } else {
536                                 if (!empty($summary)) {
537                                         $item["body"] = '[abstract]' . HTML::toBBCode($summary, $basepath) . "[/abstract]\n" . $item["body"];
538                                 }
539
540                                 if (!empty($contact["fetch_further_information"]) && ($contact["fetch_further_information"] == 3)) {
541                                         if (empty($taglist)) {
542                                                 $taglist = get_page_keywords($item["plink"], $preview, true, $contact["ffi_keyword_denylist"]);
543                                         }
544                                         $item["body"] .= "\n" . self::tagToString($taglist);
545                                 } else {
546                                         $taglist = [];
547                                 }
548
549                                 // Add the link to the original feed entry if not present in feed
550                                 if (($item['plink'] != '') && !strstr($item["body"], $item['plink'])) {
551                                         $item["body"] .= "[hr][url]" . $item['plink'] . "[/url]";
552                                 }
553                         }
554
555                         if ($dryRun) {
556                                 $items[] = $item;
557                                 break;
558                         } else {
559                                 Logger::info('Stored feed', ['item' => $item]);
560
561                                 $notify = Item::isRemoteSelf($contact, $item);
562
563                                 // Distributed items should have a well formatted URI.
564                                 // Additionally we have to avoid conflicts with identical URI between imported feeds and these items.
565                                 if ($notify) {
566                                         $item['guid'] = Item::guidFromUri($orig_plink, DI::baseUrl()->getHostname());
567                                         unset($item['uri']);
568                                         unset($item['parent-uri']);
569
570                                         // Set the delivery priority for "remote self" to "medium"
571                                         $notify = PRIORITY_MEDIUM;
572                                 }
573
574                                 $id = Item::insert($item, $notify);
575
576                                 Logger::info("Feed for contact " . $contact["url"] . " stored under id " . $id);
577
578                                 if (!empty($id) && !empty($taglist)) {
579                                         $feeditem = Item::selectFirst(['uri-id'], ['id' => $id]);
580                                         foreach ($taglist as $tag) {
581                                                 Tag::store($feeditem['uri-id'], Tag::HASHTAG, $tag);
582                                         }                                       
583                                 }
584                         }
585                 }
586
587                 return ["header" => $author, "items" => $items];
588         }
589
590         /**
591          * Convert a tag array to a tag string
592          *
593          * @param array $tags
594          * @return string tag string
595          */
596         private static function tagToString(array $tags)
597         {
598                 $tagstr = '';
599
600                 foreach ($tags as $tag) {
601                         if ($tagstr != "") {
602                                 $tagstr .= ", ";
603                         }
604         
605                         $tagstr .= "#[url=" . DI::baseUrl() . "/search?tag=" . urlencode($tag) . "]" . $tag . "[/url]";
606                 }
607
608                 return $tagstr;
609         }
610
611         private static function titleIsBody($title, $body)
612         {
613                 $title = strip_tags($title);
614                 $title = trim($title);
615                 $title = html_entity_decode($title, ENT_QUOTES, 'UTF-8');
616                 $title = str_replace(["\n", "\r", "\t", " "], ["", "", "", ""], $title);
617
618                 $body = strip_tags($body);
619                 $body = trim($body);
620                 $body = html_entity_decode($body, ENT_QUOTES, 'UTF-8');
621                 $body = str_replace(["\n", "\r", "\t", " "], ["", "", "", ""], $body);
622
623                 if (strlen($title) < strlen($body)) {
624                         $body = substr($body, 0, strlen($title));
625                 }
626
627                 if (($title != $body) && (substr($title, -3) == "...")) {
628                         $pos = strrpos($title, "...");
629                         if ($pos > 0) {
630                                 $title = substr($title, 0, $pos);
631                                 $body = substr($body, 0, $pos);
632                         }
633                 }
634                 return ($title == $body);
635         }
636 }