]> git.mxchange.org Git - friendica.git/blob - src/Util/ParseUrl.php
spelling: author
[friendica.git] / src / Util / ParseUrl.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2023, the Friendica project
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\Util;
23
24 use DOMDocument;
25 use DOMXPath;
26 use Friendica\Content\OEmbed;
27 use Friendica\Content\Text\HTML;
28 use Friendica\Protocol\HTTP\MediaType;
29 use Friendica\Core\Hook;
30 use Friendica\Core\Logger;
31 use Friendica\Database\Database;
32 use Friendica\Database\DBA;
33 use Friendica\DI;
34 use Friendica\Network\HTTPClient\Client\HttpClientAccept;
35 use Friendica\Network\HTTPException;
36 use Friendica\Network\HTTPClient\Client\HttpClientOptions;
37
38 /**
39  * Get information about a given URL
40  *
41  * Class with methods for extracting certain content from an url
42  */
43 class ParseUrl
44 {
45         const DEFAULT_EXPIRATION_FAILURE = 'now + 1 day';
46         const DEFAULT_EXPIRATION_SUCCESS = 'now + 3 months';
47
48         /**
49          * Maximum number of characters for the description
50          */
51         const MAX_DESC_COUNT = 250;
52
53         /**
54          * Minimum number of characters for the description
55          */
56         const MIN_DESC_COUNT = 100;
57
58         /**
59          * Fetch the content type of the given url
60          * @param string $url    URL of the page
61          * @param string $accept content-type to accept
62          * @param int    $timeout
63          * @return array content type
64          */
65         public static function getContentType(string $url, string $accept = HttpClientAccept::DEFAULT, int $timeout = 0): array
66         {
67                 if (!empty($timeout)) {
68                         $options = [HttpClientOptions::TIMEOUT => $timeout];
69                 } else {
70                         $options = [];
71                 }
72
73                 $curlResult = DI::httpClient()->head($url, array_merge([HttpClientOptions::ACCEPT_CONTENT => $accept], $options));
74
75                 // Workaround for systems that can't handle a HEAD request. Don't retry on timeouts.
76                 if (!$curlResult->isSuccess() && ($curlResult->getReturnCode() >= 400) && !in_array($curlResult->getReturnCode(), [408, 504])) {
77                         $curlResult = DI::httpClient()->get($url, $accept, array_merge([HttpClientOptions::CONTENT_LENGTH => 1000000], $options));
78                 }
79
80                 if (!$curlResult->isSuccess()) {
81                         Logger::debug('Got HTTP Error', ['http error' => $curlResult->getReturnCode(), 'url' => $url]);
82                         return [];
83                 }
84
85                 $contenttype =  $curlResult->getHeader('Content-Type')[0] ?? '';
86                 if (empty($contenttype)) {
87                         return ['application', 'octet-stream'];
88                 }
89
90                 return explode('/', current(explode(';', $contenttype)));
91         }
92
93         /**
94          * Search for chached embeddable data of an url otherwise fetch it
95          *
96          * @param string $url         The url of the page which should be scraped
97          * @param bool   $do_oembed   The false option is used by the function fetch_oembed()
98          *                            to avoid endless loops
99          *
100          * @return array which contains needed data for embedding
101          *    string 'url'      => The url of the parsed page
102          *    string 'type'     => Content type
103          *    string 'title'    => (optional) The title of the content
104          *    string 'text'     => (optional) The description for the content
105          *    string 'image'    => (optional) A preview image of the content
106          *    array  'images'   => (optional) Array of preview pictures
107          *    string 'keywords' => (optional) The tags which belong to the content
108          *
109          * @throws HTTPException\InternalServerErrorException
110          * @see   ParseUrl::getSiteinfo() for more information about scraping
111          * embeddable content
112          */
113         public static function getSiteinfoCached(string $url, bool $do_oembed = true): array
114         {
115                 if (empty($url)) {
116                         return [
117                                 'url' => '',
118                                 'type' => 'error',
119                         ];
120                 }
121
122                 $urlHash = hash('sha256', $url);
123
124                 $parsed_url = DBA::selectFirst('parsed_url', ['content'],
125                         ['url_hash' => $urlHash, 'oembed' => $do_oembed]
126                 );
127                 if (!empty($parsed_url['content'])) {
128                         $data = unserialize($parsed_url['content']);
129                         return $data;
130                 }
131
132                 $data = self::getSiteinfo($url, $do_oembed);
133
134                 $expires = $data['expires'];
135
136                 unset($data['expires']);
137
138                 DI::dba()->insert(
139                         'parsed_url',
140                         [
141                                 'url_hash' => $urlHash,
142                                 'oembed'   => $do_oembed,
143                                 'url'      => $url,
144                                 'content'  => serialize($data),
145                                 'created'  => DateTimeFormat::utcNow(),
146                                 'expires'  => $expires,
147                         ],
148                         Database::INSERT_UPDATE
149                 );
150
151                 return $data;
152         }
153
154         /**
155          * Parse a page for embeddable content information
156          *
157          * This method parses to url for meta data which can be used to embed
158          * the content. If available it prioritizes Open Graph meta tags.
159          * If this is not available it uses the twitter cards meta tags.
160          * As fallback it uses standard html elements with meta informations
161          * like \<title\>Awesome Title\</title\> or
162          * \<meta name="description" content="An awesome description"\>
163          *
164          * @param string $url         The url of the page which should be scraped
165          * @param bool   $do_oembed   The false option is used by the function fetch_oembed()
166          *                            to avoid endless loops
167          * @param int    $count       Internal counter to avoid endless loops
168          *
169          * @return array which contains needed data for embedding
170          *    string 'url'      => The url of the parsed page
171          *    string 'type'     => Content type (error, link, photo, image, audio, video)
172          *    string 'title'    => (optional) The title of the content
173          *    string 'text'     => (optional) The description for the content
174          *    string 'image'    => (optional) A preview image of the content
175          *    array  'images'   => (optional) Array of preview pictures
176          *    string 'keywords' => (optional) The tags which belong to the content
177          *
178          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
179          * @todo  https://developers.google.com/+/plugins/snippet/
180          * @verbatim
181          * <meta itemprop="name" content="Awesome title">
182          * <meta itemprop="description" content="An awesome description">
183          * <meta itemprop="image" content="http://maple.libertreeproject.org/images/tree-icon.png">
184          *
185          * <body itemscope itemtype="http://schema.org/Product">
186          *   <h1 itemprop="name">Shiny Trinket</h1>
187          *   <img itemprop="image" src="{image-url}" />
188          *   <p itemprop="description">Shiny trinkets are shiny.</p>
189          * </body>
190          * @endverbatim
191          */
192         public static function getSiteinfo(string $url, bool $do_oembed = true, int $count = 1): array
193         {
194                 if (empty($url)) {
195                         return [
196                                 'url' => '',
197                                 'type' => 'error',
198                         ];
199                 }
200
201                 // Check if the URL does contain a scheme
202                 $scheme = parse_url($url, PHP_URL_SCHEME);
203
204                 if ($scheme == '') {
205                         $url = 'http://' . ltrim($url, '/');
206                 }
207
208                 $url = trim($url, "'\"");
209
210                 $url = Network::stripTrackingQueryParams($url);
211
212                 $siteinfo = [
213                         'url' => $url,
214                         'type' => 'link',
215                         'expires' => DateTimeFormat::utc(self::DEFAULT_EXPIRATION_FAILURE),
216                 ];
217
218                 if ($count > 10) {
219                         Logger::warning('Endless loop detected', ['url' => $url]);
220                         return $siteinfo;
221                 }
222
223                 $type = self::getContentType($url);
224                 Logger::info('Got content-type', ['content-type' => $type, 'url' => $url]);
225                 if (!empty($type) && in_array($type[0], ['image', 'video', 'audio'])) {
226                         $siteinfo['type'] = $type[0];
227                         return $siteinfo;
228                 }
229
230                 if ((count($type) >= 2) && (($type[0] != 'text') || ($type[1] != 'html'))) {
231                         Logger::info('Unparseable content-type, quitting here, ', ['content-type' => $type, 'url' => $url]);
232                         return $siteinfo;
233                 }
234
235                 $curlResult = DI::httpClient()->get($url, HttpClientAccept::HTML, [HttpClientOptions::CONTENT_LENGTH => 1000000]);
236                 if (!$curlResult->isSuccess() || empty($curlResult->getBody())) {
237                         Logger::info('Empty body or error when fetching', ['url' => $url, 'success' => $curlResult->isSuccess(), 'code' => $curlResult->getReturnCode()]);
238                         return $siteinfo;
239                 }
240
241                 $siteinfo['expires'] = DateTimeFormat::utc(self::DEFAULT_EXPIRATION_SUCCESS);
242
243                 if ($cacheControlHeader = $curlResult->getHeader('Cache-Control')[0] ?? '') {
244                         if (preg_match('/max-age=([0-9]+)/i', $cacheControlHeader, $matches)) {
245                                 $maxAge = max(86400, (int)array_pop($matches));
246                                 $siteinfo['expires'] = DateTimeFormat::utc("now + $maxAge seconds");
247                         }
248                 }
249
250                 $body = $curlResult->getBody();
251
252                 if ($do_oembed) {
253                         $oembed_data = OEmbed::fetchURL($url, false, false);
254
255                         if (!empty($oembed_data->type)) {
256                                 if (!in_array($oembed_data->type, ['error', 'rich', 'image', 'video', 'audio', ''])) {
257                                         $siteinfo['type'] = $oembed_data->type;
258                                 }
259
260                                 // See https://github.com/friendica/friendica/pull/5763#discussion_r217913178
261                                 if ($siteinfo['type'] != 'photo') {
262                                         if (!empty($oembed_data->title)) {
263                                                 $siteinfo['title'] = trim($oembed_data->title);
264                                         }
265                                         if (!empty($oembed_data->description)) {
266                                                 $siteinfo['text'] = trim($oembed_data->description);
267                                         }
268                                         if (!empty($oembed_data->author_name)) {
269                                                 $siteinfo['author_name'] = trim($oembed_data->author_name);
270                                         }
271                                         if (!empty($oembed_data->author_url)) {
272                                                 $siteinfo['author_url'] = trim($oembed_data->author_url);
273                                         }
274                                         if (!empty($oembed_data->provider_name)) {
275                                                 $siteinfo['publisher_name'] = trim($oembed_data->provider_name);
276                                         }
277                                         if (!empty($oembed_data->provider_url)) {
278                                                 $siteinfo['publisher_url'] = trim($oembed_data->provider_url);
279                                         }
280                                         if (!empty($oembed_data->thumbnail_url)) {
281                                                 $siteinfo['image'] = $oembed_data->thumbnail_url;
282                                         }
283                                 }
284                         }
285                 }
286
287                 $charset = '';
288                 try {
289                         // Look for a charset, first in headers
290                         $mediaType = MediaType::fromContentType($curlResult->getContentType());
291                         if (isset($mediaType->parameters['charset'])) {
292                                 $charset = $mediaType->parameters['charset'];
293                         }
294                 } catch(\InvalidArgumentException $e) {}
295
296                 $siteinfo['charset'] = $charset;
297
298                 if ($charset && strtoupper($charset) != 'UTF-8') {
299                         // See https://github.com/friendica/friendica/issues/5470#issuecomment-418351211
300                         $charset = str_ireplace('latin-1', 'latin1', $charset);
301
302                         Logger::info('detected charset', ['charset' => $charset]);
303                         $body = iconv($charset, 'UTF-8//TRANSLIT', $body);
304                 }
305
306                 $body = mb_convert_encoding($body, 'HTML-ENTITIES', 'UTF-8');
307
308                 if (empty($body)) {
309                         return $siteinfo;
310                 }
311
312                 $doc = new DOMDocument();
313                 @$doc->loadHTML($body);
314
315                 $siteinfo['charset'] = HTML::extractCharset($doc) ?? $siteinfo['charset'];
316
317                 XML::deleteNode($doc, 'style');
318                 XML::deleteNode($doc, 'option');
319                 XML::deleteNode($doc, 'h1');
320                 XML::deleteNode($doc, 'h2');
321                 XML::deleteNode($doc, 'h3');
322                 XML::deleteNode($doc, 'h4');
323                 XML::deleteNode($doc, 'h5');
324                 XML::deleteNode($doc, 'h6');
325                 XML::deleteNode($doc, 'ol');
326                 XML::deleteNode($doc, 'ul');
327
328                 $xpath = new DOMXPath($doc);
329
330                 $list = $xpath->query('//meta[@content]');
331                 foreach ($list as $node) {
332                         $meta_tag = [];
333                         if ($node->attributes->length) {
334                                 foreach ($node->attributes as $attribute) {
335                                         $meta_tag[$attribute->name] = $attribute->value;
336                                 }
337                         }
338
339                         if (@$meta_tag['http-equiv'] == 'refresh') {
340                                 $path = $meta_tag['content'];
341                                 $pathinfo = explode(';', $path);
342                                 $content = '';
343                                 foreach ($pathinfo as $value) {
344                                         if (substr(strtolower($value), 0, 4) == 'url=') {
345                                                 $content = substr($value, 4);
346                                         }
347                                 }
348                                 if ($content != '') {
349                                         $siteinfo = self::getSiteinfo($content, $do_oembed, ++$count);
350                                         return $siteinfo;
351                                 }
352                         }
353                 }
354
355                 $list = $xpath->query('//title');
356                 if ($list->length > 0) {
357                         $siteinfo['title'] = trim($list->item(0)->nodeValue);
358                 }
359
360                 $list = $xpath->query('//meta[@name]');
361                 foreach ($list as $node) {
362                         $meta_tag = [];
363                         if ($node->attributes->length) {
364                                 foreach ($node->attributes as $attribute) {
365                                         $meta_tag[$attribute->name] = $attribute->value;
366                                 }
367                         }
368
369                         if (empty($meta_tag['content'])) {
370                                 continue;
371                         }
372
373                         $meta_tag['content'] = trim(html_entity_decode($meta_tag['content'], ENT_QUOTES, 'UTF-8'));
374
375                         switch (strtolower($meta_tag['name'])) {
376                                 case 'fulltitle':
377                                         $siteinfo['title'] = trim($meta_tag['content']);
378                                         break;
379                                 case 'description':
380                                         $siteinfo['text'] = trim($meta_tag['content']);
381                                         break;
382                                 case 'thumbnail':
383                                         $siteinfo['image'] = $meta_tag['content'];
384                                         break;
385                                 case 'twitter:image':
386                                         $siteinfo['image'] = $meta_tag['content'];
387                                         break;
388                                 case 'twitter:image:src':
389                                         $siteinfo['image'] = $meta_tag['content'];
390                                         break;
391                                 case 'twitter:description':
392                                         $siteinfo['text'] = trim($meta_tag['content']);
393                                         break;
394                                 case 'twitter:title':
395                                         $siteinfo['title'] = trim($meta_tag['content']);
396                                         break;
397                                 case 'twitter:player':
398                                         $siteinfo['player']['embed'] = trim($meta_tag['content']);
399                                         break;
400                                 case 'twitter:player:stream':
401                                         $siteinfo['player']['stream'] = trim($meta_tag['content']);
402                                         break;
403                                 case 'twitter:player:width':
404                                         $siteinfo['player']['width'] = intval($meta_tag['content']);
405                                         break;
406                                 case 'twitter:player:height':
407                                         $siteinfo['player']['height'] = intval($meta_tag['content']);
408                                         break;
409                                 case 'dc.title':
410                                         $siteinfo['title'] = trim($meta_tag['content']);
411                                         break;
412                                 case 'dc.description':
413                                         $siteinfo['text'] = trim($meta_tag['content']);
414                                         break;
415                                 case 'dc.creator':
416                                         $siteinfo['publisher_name'] = trim($meta_tag['content']);
417                                         break;
418                                 case 'keywords':
419                                         $keywords = explode(',', $meta_tag['content']);
420                                         break;
421                                 case 'news_keywords':
422                                         $keywords = explode(',', $meta_tag['content']);
423                                         break;
424                         }
425                 }
426
427                 if (isset($keywords)) {
428                         $siteinfo['keywords'] = [];
429                         foreach ($keywords as $keyword) {
430                                 if (!in_array(trim($keyword), $siteinfo['keywords'])) {
431                                         $siteinfo['keywords'][] = trim($keyword);
432                                 }
433                         }
434                 }
435
436                 $list = $xpath->query('//meta[@property]');
437                 foreach ($list as $node) {
438                         $meta_tag = [];
439                         if ($node->attributes->length) {
440                                 foreach ($node->attributes as $attribute) {
441                                         $meta_tag[$attribute->name] = $attribute->value;
442                                 }
443                         }
444
445                         if (!empty($meta_tag['content'])) {
446                                 $meta_tag['content'] = trim(html_entity_decode($meta_tag['content'], ENT_QUOTES, 'UTF-8'));
447
448                                 switch (strtolower($meta_tag['property'])) {
449                                         case 'og:image':
450                                                 $siteinfo['image'] = $meta_tag['content'];
451                                                 break;
452                                         case 'og:image:url':
453                                                 $siteinfo['image'] = $meta_tag['content'];
454                                                 break;
455                                         case 'og:image:secure_url':
456                                                 $siteinfo['image'] = $meta_tag['content'];
457                                                 break;
458                                         case 'og:title':
459                                                 $siteinfo['title'] = trim($meta_tag['content']);
460                                                 break;
461                                         case 'og:description':
462                                                 $siteinfo['text'] = trim($meta_tag['content']);
463                                                 break;
464                                         case 'og:site_name':
465                                                 $siteinfo['publisher_name'] = trim($meta_tag['content']);
466                                                 break;
467                                         case 'og:locale':
468                                                 $siteinfo['language'] = trim($meta_tag['content']);
469                                                 break;
470                                         case 'og:type':
471                                                 $siteinfo['pagetype'] = trim($meta_tag['content']);
472                                                 break;
473                                         case 'twitter:description':
474                                                 $siteinfo['text'] = trim($meta_tag['content']);
475                                                 break;
476                                         case 'twitter:title':
477                                                 $siteinfo['title'] = trim($meta_tag['content']);
478                                                 break;
479                                         case 'twitter:image':
480                                                 $siteinfo['image'] = $meta_tag['content'];
481                                                 break;
482                                 }
483                         }
484                 }
485
486                 $list = $xpath->query("//script[@type='application/ld+json']");
487                 foreach ($list as $node) {
488                         if (!empty($node->nodeValue)) {
489                                 if ($jsonld = json_decode($node->nodeValue, true)) {
490                                         $siteinfo = self::parseParts($siteinfo, $jsonld);
491                                 }
492                         }
493                 }
494
495                 if (!empty($siteinfo['player']['stream'])) {
496                         // Only add player data to media arrays if there is no duplicate
497                         $content_urls = array_merge(array_column($siteinfo['audio'] ?? [], 'content'), array_column($siteinfo['video'] ?? [], 'content'));
498                         if (!in_array($siteinfo['player']['stream'], $content_urls)) {
499                                 $contenttype = self::getContentType($siteinfo['player']['stream']);
500                                 if (!empty($contenttype[0]) && in_array($contenttype[0], ['audio', 'video'])) {
501                                         $media = ['content' => $siteinfo['player']['stream']];
502
503                                         if (!empty($siteinfo['player']['embed'])) {
504                                                 $media['embed'] = $siteinfo['player']['embed'];
505                                         }
506
507                                         $siteinfo[$contenttype[0]][] = $media;
508                                 }
509                         }
510                 }
511
512                 if (!empty($siteinfo['image'])) {
513                         $siteinfo['images'] = $siteinfo['images'] ?? [];
514                         array_unshift($siteinfo['images'], ['url' => $siteinfo['image']]);
515                         unset($siteinfo['image']);
516                 }
517
518                 $siteinfo = self::checkMedia($url, $siteinfo);
519
520                 if (!empty($siteinfo['text']) && mb_strlen($siteinfo['text']) > self::MAX_DESC_COUNT) {
521                         $siteinfo['text'] = mb_substr($siteinfo['text'], 0, self::MAX_DESC_COUNT) . '…';
522                         $pos = mb_strrpos($siteinfo['text'], '.');
523                         if ($pos > self::MIN_DESC_COUNT) {
524                                 $siteinfo['text'] = mb_substr($siteinfo['text'], 0, $pos + 1);
525                         }
526                 }
527
528                 Logger::info('Siteinfo fetched', ['url' => $url, 'siteinfo' => $siteinfo]);
529
530                 Hook::callAll('getsiteinfo', $siteinfo);
531
532                 ksort($siteinfo);
533
534                 return $siteinfo;
535         }
536
537         /**
538          * Check the attached media elements.
539          * Fix existing data and add missing data.
540          *
541          * @param string $page_url
542          * @param array $siteinfo
543          * @return array
544          */
545         private static function checkMedia(string $page_url, array $siteinfo) : array
546         {
547                 if (!empty($siteinfo['images'])) {
548                         array_walk($siteinfo['images'], function (&$image) use ($page_url) {
549                                 /*
550                                  * According to the specifications someone could place a picture
551                                  * URL into the content field as well. But this doesn't seem to
552                                  * happen in the wild, so we don't cover it here.
553                                  */
554                                 if (!empty($image['url'])) {
555                                         $image['url'] = self::completeUrl($image['url'], $page_url);
556                                         $photodata = Images::getInfoFromURLCached($image['url']);
557                                         if (($photodata) && ($photodata[0] > 50) && ($photodata[1] > 50)) {
558                                                 $image['src'] = $image['url'];
559                                                 $image['width'] = $photodata[0];
560                                                 $image['height'] = $photodata[1];
561                                                 $image['contenttype'] = $photodata['mime'];
562                                                 $image['blurhash'] = $photodata['blurhash'] ?? null;
563                                                 unset($image['url']);
564                                                 ksort($image);
565                                         } else {
566                                                 $image = [];
567                                         }
568                                 } else {
569                                         $image = [];
570                                 }
571                         });
572
573                         $siteinfo['images'] = array_values(array_filter($siteinfo['images']));
574                 }
575
576                 foreach (['audio', 'video'] as $element) {
577                         if (!empty($siteinfo[$element])) {
578                                 array_walk($siteinfo[$element], function (&$media) use ($page_url, &$siteinfo) {
579                                         $url = '';
580                                         $embed = '';
581                                         $content = '';
582                                         $contenttype = '';
583                                         foreach (['embed', 'content', 'url'] as $field) {
584                                                 if (!empty($media[$field])) {
585                                                         $media[$field] = self::completeUrl($media[$field], $page_url);
586                                                         $type = self::getContentType($media[$field]);
587                                                         if (($type[0] ?? '') == 'text') {
588                                                                 if ($field == 'embed') {
589                                                                         $embed = $media[$field];
590                                                                 } else {
591                                                                         $url = $media[$field];
592                                                                 }
593                                                         } elseif (!empty($type[0])) {
594                                                                 $content = $media[$field];
595                                                                 $contenttype = implode('/', $type);
596                                                         }
597                                                 }
598                                                 unset($media[$field]);
599                                         }
600
601                                         foreach (['image', 'preview'] as $field) {
602                                                 if (!empty($media[$field])) {
603                                                         $media[$field] = self::completeUrl($media[$field], $page_url);
604                                                 }
605                                         }
606
607                                         if (!empty($url)) {
608                                                 $media['url'] = $url;
609                                         }
610                                         if (!empty($embed)) {
611                                                 $media['embed'] = $embed;
612                                                 if (empty($siteinfo['player']['embed'])) {
613                                                         $siteinfo['player']['embed'] = $embed;
614                                                 }
615                                         }
616                                         if (!empty($content)) {
617                                                 $media['src'] = $content;
618                                         }
619                                         if (!empty($contenttype)) {
620                                                 $media['contenttype'] = $contenttype;
621                                         }
622                                         if (empty($url) && empty($content) && empty($embed)) {
623                                                 $media = [];
624                                         }
625                                         ksort($media);
626                                 });
627
628                                 $siteinfo[$element] = array_values(array_filter($siteinfo[$element]));
629                         }
630                         if (empty($siteinfo[$element])) {
631                                 unset($siteinfo[$element]);
632                         }
633                 }
634                 return $siteinfo;
635         }
636
637         /**
638          * Convert tags from CSV to an array
639          *
640          * @param string $string Tags
641          *
642          * @return array with formatted Hashtags
643          */
644         public static function convertTagsToArray(string $string): array
645         {
646                 $arr_tags = str_getcsv($string);
647                 if (count($arr_tags)) {
648                         // add the # sign to every tag
649                         array_walk($arr_tags, [self::class, 'arrAddHashes']);
650
651                         return $arr_tags;
652                 }
653                 return [];
654         }
655
656         /**
657          * Add a hasht sign to a string
658          *
659          * This method is used as callback function
660          *
661          * @param string $tag The pure tag name
662          * @param int    $k   Counter for internal use
663          *
664          * @return void
665          */
666         private static function arrAddHashes(string &$tag, int $k)
667         {
668                 $tag = '#' . $tag;
669         }
670
671         /**
672          * Add a scheme to an url
673          *
674          * The src attribute of some html elements (e.g. images)
675          * can miss the scheme so we need to add the correct
676          * scheme
677          *
678          * @param string $url    The url which possibly does have
679          *                       a missing scheme (a link to an image)
680          * @param string $scheme The url with a correct scheme
681          *                       (e.g. the url from the webpage which does contain the image)
682          *
683          * @return string The url with a scheme
684          */
685         private static function completeUrl(string $url, string $scheme): string
686         {
687                 $urlarr = parse_url($url);
688
689                 // If the url does already have an scheme
690                 // we can stop the process here
691                 if (isset($urlarr['scheme'])) {
692                         return $url;
693                 }
694
695                 $schemearr = parse_url($scheme);
696
697                 $complete = $schemearr['scheme'] . '://' . $schemearr['host'];
698
699                 if (!empty($schemearr['port'])) {
700                         $complete .= ':' . $schemearr['port'];
701                 }
702
703                 if (!empty($urlarr['path'])) {
704                         if (strpos($urlarr['path'], '/') !== 0) {
705                                 $complete .= '/';
706                         }
707
708                         $complete .= $urlarr['path'];
709                 }
710
711                 if (!empty($urlarr['query'])) {
712                         $complete .= '?' . $urlarr['query'];
713                 }
714
715                 if (!empty($urlarr['fragment'])) {
716                         $complete .= '#' . $urlarr['fragment'];
717                 }
718
719                 return $complete;
720         }
721
722         /**
723          * Parse the Json-Ld parts of a web page
724          *
725          * @param array $siteinfo
726          * @param array $jsonld
727          *
728          * @return array siteinfo
729          */
730         private static function parseParts(array $siteinfo, array $jsonld): array
731         {
732                 if (!empty($jsonld['@graph']) && is_array($jsonld['@graph'])) {
733                         foreach ($jsonld['@graph'] as $part) {
734                                 if (!empty($part) && is_array($part)) {
735                                         $siteinfo = self::parseParts($siteinfo, $part);
736                                 }
737                         }
738                 } elseif (!empty($jsonld['@type'])) {
739                         $siteinfo = self::parseJsonLd($siteinfo, $jsonld);
740                 } elseif (!empty($jsonld)) {
741                         $keys = array_keys($jsonld);
742                         $numeric_keys = true;
743                         foreach ($keys as $key) {
744                                 if (!is_int($key)) {
745                                         $numeric_keys = false;
746                                 }
747                         }
748                         if ($numeric_keys) {
749                                 foreach ($jsonld as $part) {
750                                         if (!empty($part) && is_array($part)) {
751                                                 $siteinfo = self::parseParts($siteinfo, $part);
752                                         }
753                                 }
754                         }
755                 }
756
757                 array_walk_recursive($siteinfo, function (&$element) {
758                         if (is_string($element)) {
759                                 $element = trim(strip_tags(html_entity_decode($element, ENT_COMPAT, 'UTF-8')));
760                         }
761                 });
762
763                 return $siteinfo;
764         }
765
766         /**
767          * Improve the siteinfo with information from the provided JSON-LD information
768          * @see https://jsonld.com/
769          * @see https://schema.org/
770          *
771          * @param array $siteinfo
772          * @param array $jsonld
773          *
774          * @return array siteinfo
775          */
776         private static function parseJsonLd(array $siteinfo, array $jsonld): array
777         {
778                 $type = JsonLD::fetchElement($jsonld, '@type');
779                 if (empty($type)) {
780                         Logger::info('Empty type', ['url' => $siteinfo['url']]);
781                         return $siteinfo;
782                 }
783
784                 // Silently ignore some types that aren't processed
785                 if (in_array($type, ['SiteNavigationElement', 'JobPosting', 'CreativeWork', 'MusicAlbum',
786                         'WPHeader', 'WPSideBar', 'WPFooter', 'LegalService', 'MusicRecording',
787                         'ItemList', 'BreadcrumbList', 'Blog', 'Dataset', 'Product'])) {
788                         return $siteinfo;
789                 }
790
791                 switch ($type) {
792                         case 'Article':
793                         case 'AdvertiserContentArticle':
794                         case 'NewsArticle':
795                         case 'Report':
796                         case 'SatiricalArticle':
797                         case 'ScholarlyArticle':
798                         case 'SocialMediaPosting':
799                         case 'TechArticle':
800                         case 'ReportageNewsArticle':
801                         case 'SocialMediaPosting':
802                         case 'BlogPosting':
803                         case 'LiveBlogPosting':
804                         case 'DiscussionForumPosting':
805                                 return self::parseJsonLdArticle($siteinfo, $jsonld);
806                         case 'WebPage':
807                         case 'AboutPage':
808                         case 'CheckoutPage':
809                         case 'CollectionPage':
810                         case 'ContactPage':
811                         case 'FAQPage':
812                         case 'ItemPage':
813                         case 'MedicalWebPage':
814                         case 'ProfilePage':
815                         case 'QAPage':
816                         case 'RealEstateListing':
817                         case 'SearchResultsPage':
818                         case 'MediaGallery':
819                         case 'ImageGallery':
820                         case 'VideoGallery':
821                         case 'RadioEpisode':
822                         case 'Event':
823                                 return self::parseJsonLdWebPage($siteinfo, $jsonld);
824                         case 'WebSite':
825                                 return self::parseJsonLdWebSite($siteinfo, $jsonld);
826                         case 'Organization':
827                         case 'Airline':
828                         case 'Consortium':
829                         case 'Corporation':
830                         case 'EducationalOrganization':
831                         case 'FundingScheme':
832                         case 'GovernmentOrganization':
833                         case 'LibrarySystem':
834                         case 'LocalBusiness':
835                         case 'MedicalOrganization':
836                         case 'NGO':
837                         case 'NewsMediaOrganization':
838                         case 'Project':
839                         case 'SportsOrganization':
840                         case 'WorkersUnion':
841                                 return self::parseJsonLdWebOrganization($siteinfo, $jsonld);
842                         case 'Person':
843                         case 'Patient':
844                         case 'PerformingGroup':
845                         case 'DanceGroup';
846                         case 'MusicGroup':
847                         case 'TheaterGroup':
848                                 return self::parseJsonLdWebPerson($siteinfo, $jsonld);
849                         case 'AudioObject':
850                         case 'Audio':
851                                 return self::parseJsonLdMediaObject($siteinfo, $jsonld, 'audio');
852                         case 'VideoObject':
853                                 return self::parseJsonLdMediaObject($siteinfo, $jsonld, 'video');
854                         case 'ImageObject':
855                                 return self::parseJsonLdMediaObject($siteinfo, $jsonld, 'images');
856                         default:
857                                 Logger::info('Unknown type', ['type' => $type, 'url' => $siteinfo['url']]);
858                                 return $siteinfo;
859                 }
860         }
861
862         /**
863          * Fetch author and publisher data
864          *
865          * @param array $siteinfo
866          * @param array $jsonld
867          *
868          * @return array siteinfo
869          */
870         private static function parseJsonLdAuthor(array $siteinfo, array $jsonld): array
871         {
872                 $jsonldinfo = [];
873
874                 if (!empty($jsonld['publisher']) && is_array($jsonld['publisher'])) {
875                         $content = JsonLD::fetchElement($jsonld, 'publisher', 'name');
876                         if (!empty($content) && is_string($content)) {
877                                 $jsonldinfo['publisher_name'] = trim($content);
878                         }
879
880                         $content = JsonLD::fetchElement($jsonld, 'publisher', 'url');
881                         if (!empty($content) && is_string($content)) {
882                                 $jsonldinfo['publisher_url'] = trim($content);
883                         }
884
885                         $brand = JsonLD::fetchElement($jsonld, 'publisher', 'brand', '@type', 'Organization');
886                         if (!empty($brand) && is_array($brand)) {
887                                 $content = JsonLD::fetchElement($brand, 'name');
888                                 if (!empty($content) && is_string($content)) {
889                                         $jsonldinfo['publisher_name'] = trim($content);
890                                 }
891
892                                 $content = JsonLD::fetchElement($brand, 'url');
893                                 if (!empty($content) && is_string($content)) {
894                                         $jsonldinfo['publisher_url'] = trim($content);
895                                 }
896
897                                 $content = JsonLD::fetchElement($brand, 'logo', 'url');
898                                 if (!empty($content) && is_string($content)) {
899                                         $jsonldinfo['publisher_img'] = trim($content);
900                                 }
901                         }
902
903                         $logo = JsonLD::fetchElement($jsonld, 'publisher', 'logo');
904                         if (!empty($logo) && is_array($logo)) {
905                                 $content = JsonLD::fetchElement($logo, 'url');
906                                 if (!empty($content) && is_string($content)) {
907                                         $jsonldinfo['publisher_img'] = trim($content);
908                                 }
909                         }
910                 } elseif (!empty($jsonld['publisher']) && is_string($jsonld['publisher'])) {
911                         $jsonldinfo['publisher_name'] = trim($jsonld['publisher']);
912                 }
913
914                 if (!empty($jsonld['author']) && is_array($jsonld['author'])) {
915                         $content = JsonLD::fetchElement($jsonld, 'author', 'name');
916                         if (!empty($content) && is_string($content)) {
917                                 $jsonldinfo['author_name'] = trim($content);
918                         }
919
920                         $content = JsonLD::fetchElement($jsonld, 'author', 'sameAs');
921                         if (!empty($content) && is_string($content)) {
922                                 $jsonldinfo['author_url'] = trim($content);
923                         }
924
925                         $content = JsonLD::fetchElement($jsonld, 'author', 'url');
926                         if (!empty($content) && is_string($content)) {
927                                 $jsonldinfo['author_url'] = trim($content);
928                         }
929
930                         $logo = JsonLD::fetchElement($jsonld, 'author', 'logo');
931                         if (!empty($logo) && is_array($logo)) {
932                                 $content = JsonLD::fetchElement($logo, 'url');
933                                 if (!empty($content) && is_string($content)) {
934                                         $jsonldinfo['author_img'] = trim($content);
935                                 }
936                         }
937                 } elseif (!empty($jsonld['author']) && is_string($jsonld['author'])) {
938                         $jsonldinfo['author_name'] = trim($jsonld['author']);
939                 }
940
941                 Logger::info('Fetched Author information', ['fetched' => $jsonldinfo]);
942
943                 return array_merge($siteinfo, $jsonldinfo);
944         }
945
946         /**
947          * Fetch data from the provided JSON-LD Article type
948          * @see https://schema.org/Article
949          *
950          * @param array $siteinfo
951          * @param array $jsonld
952          *
953          * @return array siteinfo
954          */
955         private static function parseJsonLdArticle(array $siteinfo, array $jsonld): array
956         {
957                 $jsonldinfo = [];
958
959                 $content = JsonLD::fetchElement($jsonld, 'headline');
960                 if (!empty($content) && is_string($content)) {
961                         $jsonldinfo['title'] = trim($content);
962                 }
963
964                 $content = JsonLD::fetchElement($jsonld, 'alternativeHeadline');
965                 if (!empty($content) && is_string($content) && (($jsonldinfo['title'] ?? '') != trim($content))) {
966                         $jsonldinfo['alternative_title'] = trim($content);
967                 }
968
969                 $content = JsonLD::fetchElement($jsonld, 'description');
970                 if (!empty($content) && is_string($content)) {
971                         $jsonldinfo['text'] = trim($content);
972                 }
973
974                 $content = JsonLD::fetchElement($jsonld, 'thumbnailUrl');
975                 if (!empty($content)) {
976                         $jsonldinfo['image'] = trim($content);
977                 }
978
979                 $content = JsonLD::fetchElement($jsonld, 'image', 'url', '@type', 'ImageObject');
980                 if (!empty($content) && is_string($content)) {
981                         $jsonldinfo['image'] = trim($content);
982                 }
983
984                 if (!empty($jsonld['keywords']) && !is_array($jsonld['keywords'])) {
985                         $content = JsonLD::fetchElement($jsonld, 'keywords');
986                         if (!empty($content)) {
987                                 $siteinfo['keywords'] = [];
988                                 $keywords = explode(',', $content);
989                                 foreach ($keywords as $keyword) {
990                                         $siteinfo['keywords'][] = trim($keyword);
991                                 }
992                         }
993                 } elseif (!empty($jsonld['keywords'])) {
994                         $content = JsonLD::fetchElementArray($jsonld, 'keywords');
995                         if (!empty($content) && is_array($content)) {
996                                 $jsonldinfo['keywords'] = $content;
997                         }
998                 }
999
1000                 $content = JsonLD::fetchElement($jsonld, 'datePublished');
1001                 if (!empty($content) && is_string($content)) {
1002                         $jsonldinfo['published'] = DateTimeFormat::utc($content);
1003                 }
1004
1005                 $content = JsonLD::fetchElement($jsonld, 'dateModified');
1006                 if (!empty($content) && is_string($content)) {
1007                         $jsonldinfo['modified'] = DateTimeFormat::utc($content);
1008                 }
1009
1010                 $jsonldinfo = self::parseJsonLdAuthor($jsonldinfo, $jsonld);
1011
1012                 Logger::info('Fetched article information', ['url' => $siteinfo['url'], 'fetched' => $jsonldinfo]);
1013
1014                 return array_merge($siteinfo, $jsonldinfo);
1015         }
1016
1017         /**
1018          * Fetch data from the provided JSON-LD WebPage type
1019          * @see https://schema.org/WebPage
1020          *
1021          * @param array $siteinfo
1022          * @param array $jsonld
1023          *
1024          * @return array siteinfo
1025          */
1026         private static function parseJsonLdWebPage(array $siteinfo, array $jsonld): array
1027         {
1028                 $jsonldinfo = [];
1029
1030                 $content = JsonLD::fetchElement($jsonld, 'name');
1031                 if (!empty($content)) {
1032                         $jsonldinfo['title'] = trim($content);
1033                 }
1034
1035                 $content = JsonLD::fetchElement($jsonld, 'description');
1036                 if (!empty($content) && is_string($content)) {
1037                         $jsonldinfo['text'] = trim($content);
1038                 }
1039
1040                 $content = JsonLD::fetchElement($jsonld, 'image');
1041                 if (!empty($content) && is_string($content)) {
1042                         $jsonldinfo['image'] = trim($content);
1043                 }
1044
1045                 $content = JsonLD::fetchElement($jsonld, 'thumbnailUrl');
1046                 if (!empty($content) && is_string($content)) {
1047                         $jsonldinfo['image'] = trim($content);
1048                 }
1049
1050                 $jsonldinfo = self::parseJsonLdAuthor($jsonldinfo, $jsonld);
1051
1052                 Logger::info('Fetched WebPage information', ['url' => $siteinfo['url'], 'fetched' => $jsonldinfo]);
1053
1054                 return array_merge($siteinfo, $jsonldinfo);
1055         }
1056
1057         /**
1058          * Fetch data from the provided JSON-LD WebSite type
1059          * @see https://schema.org/WebSite
1060          *
1061          * @param array $siteinfo
1062          * @param array $jsonld
1063          *
1064          * @return array siteinfo
1065          */
1066         private static function parseJsonLdWebSite(array $siteinfo, array $jsonld): array
1067         {
1068                 $jsonldinfo = [];
1069
1070                 $content = JsonLD::fetchElement($jsonld, 'name');
1071                 if (!empty($content) && is_string($content)) {
1072                         $jsonldinfo['publisher_name'] = trim($content);
1073                 }
1074
1075                 $content = JsonLD::fetchElement($jsonld, 'description');
1076                 if (!empty($content) && is_string($content)) {
1077                         $jsonldinfo['publisher_description'] = trim($content);
1078                 }
1079
1080                 $content = JsonLD::fetchElement($jsonld, 'url');
1081                 if (!empty($content) && is_string($content)) {
1082                         $jsonldinfo['publisher_url'] = trim($content);
1083                 }
1084
1085                 $content = JsonLD::fetchElement($jsonld, 'thumbnailUrl');
1086                 if (!empty($content) && is_string($content)) {
1087                         $jsonldinfo['image'] = trim($content);
1088                 }
1089
1090                 $jsonldinfo = self::parseJsonLdAuthor($jsonldinfo, $jsonld);
1091
1092                 Logger::info('Fetched WebSite information', ['url' => $siteinfo['url'], 'fetched' => $jsonldinfo]);
1093                 return array_merge($siteinfo, $jsonldinfo);
1094         }
1095
1096         /**
1097          * Fetch data from the provided JSON-LD Organization type
1098          * @see https://schema.org/Organization
1099          *
1100          * @param array $siteinfo
1101          * @param array $jsonld
1102          *
1103          * @return array siteinfo
1104          */
1105         private static function parseJsonLdWebOrganization(array $siteinfo, array $jsonld): array
1106         {
1107                 $jsonldinfo = [];
1108
1109                 $content = JsonLD::fetchElement($jsonld, 'name');
1110                 if (!empty($content) && is_string($content)) {
1111                         $jsonldinfo['publisher_name'] = trim($content);
1112                 }
1113
1114                 $content = JsonLD::fetchElement($jsonld, 'description');
1115                 if (!empty($content) && is_string($content)) {
1116                         $jsonldinfo['publisher_description'] = trim($content);
1117                 }
1118
1119                 $content = JsonLD::fetchElement($jsonld, 'url');
1120                 if (!empty($content) && is_string($content)) {
1121                         $jsonldinfo['publisher_url'] = trim($content);
1122                 }
1123
1124                 $content = JsonLD::fetchElement($jsonld, 'logo', 'url', '@type', 'ImageObject');
1125                 if (!empty($content) && is_string($content)) {
1126                         $jsonldinfo['publisher_img'] = trim($content);
1127                 } elseif (!empty($content) && is_array($content)) {
1128                         $jsonldinfo['publisher_img'] = trim($content[0]);
1129                 }
1130
1131                 $content = JsonLD::fetchElement($jsonld, 'brand', 'name', '@type', 'Organization');
1132                 if (!empty($content) && is_string($content)) {
1133                         $jsonldinfo['publisher_name'] = trim($content);
1134                 }
1135
1136                 $content = JsonLD::fetchElement($jsonld, 'brand', 'url', '@type', 'Organization');
1137                 if (!empty($content) && is_string($content)) {
1138                         $jsonldinfo['publisher_url'] = trim($content);
1139                 }
1140
1141                 Logger::info('Fetched Organization information', ['url' => $siteinfo['url'], 'fetched' => $jsonldinfo]);
1142                 return array_merge($siteinfo, $jsonldinfo);
1143         }
1144
1145         /**
1146          * Fetch data from the provided JSON-LD Person type
1147          * @see https://schema.org/Person
1148          *
1149          * @param array $siteinfo
1150          * @param array $jsonld
1151          *
1152          * @return array siteinfo
1153          */
1154         private static function parseJsonLdWebPerson(array $siteinfo, array $jsonld): array
1155         {
1156                 $jsonldinfo = [];
1157
1158                 $content = JsonLD::fetchElement($jsonld, 'name');
1159                 if (!empty($content) && is_string($content)) {
1160                         $jsonldinfo['author_name'] = trim($content);
1161                 }
1162
1163                 $content = JsonLD::fetchElement($jsonld, 'description');
1164                 if (!empty($content) && is_string($content)) {
1165                         $jsonldinfo['author_description'] = trim($content);
1166                 }
1167
1168                 $content = JsonLD::fetchElement($jsonld, 'sameAs');
1169                 if (!empty($content) && is_string($content)) {
1170                         $jsonldinfo['author_url'] = trim($content);
1171                 }
1172
1173                 $content = JsonLD::fetchElement($jsonld, 'url');
1174                 if (!empty($content) && is_string($content)) {
1175                         $jsonldinfo['author_url'] = trim($content);
1176                 }
1177
1178                 $content = JsonLD::fetchElement($jsonld, 'image', 'url', '@type', 'ImageObject');
1179                 if (!empty($content) && !is_string($content)) {
1180                         Logger::notice('Unexpected return value for the author image', ['content' => $content]);
1181                 }
1182
1183                 if (!empty($content) && is_string($content)) {
1184                         $jsonldinfo['author_img'] = trim($content);
1185                 }
1186
1187                 Logger::info('Fetched Person information', ['url' => $siteinfo['url'], 'fetched' => $jsonldinfo]);
1188                 return array_merge($siteinfo, $jsonldinfo);
1189         }
1190
1191         /**
1192          * Fetch data from the provided JSON-LD MediaObject type
1193          * @see https://schema.org/MediaObject
1194          *
1195          * @param array $siteinfo
1196          * @param array $jsonld
1197          *
1198          * @return array siteinfo
1199          */
1200         private static function parseJsonLdMediaObject(array $siteinfo, array $jsonld, string $name): array
1201         {
1202                 $media = [];
1203
1204                 $content = JsonLD::fetchElement($jsonld, 'caption');
1205                 if (!empty($content) && is_string($content)) {
1206                         $media['caption'] = trim($content);
1207                 }
1208
1209                 $content = JsonLD::fetchElement($jsonld, 'url');
1210                 if (!empty($content) && is_string($content)) {
1211                         $media['url'] = trim($content);
1212                 }
1213
1214                 $content = JsonLD::fetchElement($jsonld, 'mainEntityOfPage');
1215                 if (!empty($content) && is_string($content)) {
1216                         $media['main'] = Strings::compareLink($content, $siteinfo['url']);
1217                 }
1218
1219                 $content = JsonLD::fetchElement($jsonld, 'description');
1220                 if (!empty($content) && is_string($content)) {
1221                         $media['description'] = trim($content);
1222                 }
1223
1224                 $content = JsonLD::fetchElement($jsonld, 'name');
1225                 if (!empty($content) && (($media['description'] ?? '') != trim($content))) {
1226                         $media['name'] = trim($content);
1227                 }
1228
1229                 $content = JsonLD::fetchElement($jsonld, 'contentUrl');
1230                 if (!empty($content) && is_string($content)) {
1231                         $media['content'] = trim($content);
1232                 }
1233
1234                 $content = JsonLD::fetchElement($jsonld, 'embedUrl');
1235                 if (!empty($content) && is_string($content)) {
1236                         $media['embed'] = trim($content);
1237                 }
1238
1239                 $content = JsonLD::fetchElement($jsonld, 'height');
1240                 if (!empty($content) && is_string($content)) {
1241                         $media['height'] = trim($content);
1242                 }
1243
1244                 $content = JsonLD::fetchElement($jsonld, 'width');
1245                 if (!empty($content) && is_string($content)) {
1246                         $media['width'] = trim($content);
1247                 }
1248
1249                 $content = JsonLD::fetchElement($jsonld, 'image');
1250                 if (!empty($content) && is_string($content)) {
1251                         $media['image'] = trim($content);
1252                 }
1253
1254                 $content = JsonLD::fetchElement($jsonld, 'thumbnailUrl');
1255                 if (!empty($content) && (($media['image'] ?? '') != trim($content))) {
1256                         if (!empty($media['image'])) {
1257                                 $media['preview'] = trim($content);
1258                         } else {
1259                                 $media['image'] = trim($content);
1260                         }
1261                 }
1262
1263                 Logger::info('Fetched Media information', ['url' => $siteinfo['url'], 'fetched' => $media]);
1264                 $siteinfo[$name][] = $media;
1265                 return $siteinfo;
1266         }
1267 }