]> git.mxchange.org Git - friendica.git/blob - src/Util/ParseUrl.php
Move Object\Image static methods to Util\Images
[friendica.git] / src / Util / ParseUrl.php
1 <?php
2 /**
3  * @file src/Util/ParseUrl.php
4  * @brief Get informations about a given URL
5  */
6 namespace Friendica\Util;
7
8 use DOMDocument;
9 use DOMXPath;
10 use Friendica\Content\OEmbed;
11 use Friendica\Core\Hook;
12 use Friendica\Core\Logger;
13 use Friendica\Database\DBA;
14
15 /**
16  * @brief Class with methods for extracting certain content from an url
17  */
18 class ParseUrl
19 {
20         /**
21          * @brief Search for chached embeddable data of an url otherwise fetch it
22          *
23          * @param string $url         The url of the page which should be scraped
24          * @param bool   $no_guessing If true the parse doens't search for
25          *                            preview pictures
26          * @param bool   $do_oembed   The false option is used by the function fetch_oembed()
27          *                            to avoid endless loops
28          *
29          * @return array which contains needed data for embedding
30          *    string 'url' => The url of the parsed page
31          *    string 'type' => Content type
32          *    string 'title' => The title of the content
33          *    string 'text' => The description for the content
34          *    string 'image' => A preview image of the content (only available
35          *                if $no_geuessing = false
36          *    array'images' = Array of preview pictures
37          *    string 'keywords' => The tags which belong to the content
38          *
39          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
40          * @see   ParseUrl::getSiteinfo() for more information about scraping
41          * embeddable content
42          */
43         public static function getSiteinfoCached($url, $no_guessing = false, $do_oembed = true)
44         {
45                 if ($url == "") {
46                         return false;
47                 }
48
49                 $parsed_url = DBA::selectFirst('parsed_url', ['content'],
50                         ['url' => Strings::normaliseLink($url), 'guessing' => !$no_guessing, 'oembed' => $do_oembed]
51                 );
52                 if (!empty($parsed_url['content'])) {
53                         $data = unserialize($parsed_url['content']);
54                         return $data;
55                 }
56
57                 $data = self::getSiteinfo($url, $no_guessing, $do_oembed);
58
59                 DBA::insert(
60                         'parsed_url',
61                         [
62                                 'url' => Strings::normaliseLink($url), 'guessing' => !$no_guessing,
63                                 'oembed' => $do_oembed, 'content' => serialize($data),
64                                 'created' => DateTimeFormat::utcNow()
65                         ],
66                         true
67                 );
68
69                 return $data;
70         }
71
72         /**
73          * @brief Parse a page for embeddable content information
74          *
75          * This method parses to url for meta data which can be used to embed
76          * the content. If available it prioritizes Open Graph meta tags.
77          * If this is not available it uses the twitter cards meta tags.
78          * As fallback it uses standard html elements with meta informations
79          * like \<title\>Awesome Title\</title\> or
80          * \<meta name="description" content="An awesome description"\>
81          *
82          * @param string $url         The url of the page which should be scraped
83          * @param bool   $no_guessing If true the parse doens't search for
84          *                            preview pictures
85          * @param bool   $do_oembed   The false option is used by the function fetch_oembed()
86          *                            to avoid endless loops
87          * @param int    $count       Internal counter to avoid endless loops
88          *
89          * @return array which contains needed data for embedding
90          *    string 'url' => The url of the parsed page
91          *    string 'type' => Content type
92          *    string 'title' => The title of the content
93          *    string 'text' => The description for the content
94          *    string 'image' => A preview image of the content (only available
95          *                if $no_geuessing = false
96          *    array'images' = Array of preview pictures
97          *    string 'keywords' => The tags which belong to the content
98          *
99          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
100          * @todo  https://developers.google.com/+/plugins/snippet/
101          * @verbatim
102          * <meta itemprop="name" content="Awesome title">
103          * <meta itemprop="description" content="An awesome description">
104          * <meta itemprop="image" content="http://maple.libertreeproject.org/images/tree-icon.png">
105          *
106          * <body itemscope itemtype="http://schema.org/Product">
107          *   <h1 itemprop="name">Shiny Trinket</h1>
108          *   <img itemprop="image" src="{image-url}" />
109          *   <p itemprop="description">Shiny trinkets are shiny.</p>
110          * </body>
111          * @endverbatim
112          */
113         public static function getSiteinfo($url, $no_guessing = false, $do_oembed = true, $count = 1)
114         {
115                 $siteinfo = [];
116
117                 // Check if the URL does contain a scheme
118                 $scheme = parse_url($url, PHP_URL_SCHEME);
119
120                 if ($scheme == '') {
121                         $url = 'http://' . trim($url, '/');
122                 }
123
124                 if ($count > 10) {
125                         Logger::log('Endless loop detected for ' . $url, Logger::DEBUG);
126                         return $siteinfo;
127                 }
128
129                 $url = trim($url, "'");
130                 $url = trim($url, '"');
131
132                 $url = Network::stripTrackingQueryParams($url);
133
134                 $siteinfo['url'] = $url;
135                 $siteinfo['type'] = 'link';
136
137                 $curlResult = Network::curl($url);
138                 if (!$curlResult->isSuccess()) {
139                         return $siteinfo;
140                 }
141
142                 // If the file is too large then exit
143                 if (($curlResult->getInfo()['download_content_length'] ?? 0) > 1000000) {
144                         return $siteinfo;
145                 }
146
147                 // If it isn't a HTML file then exit
148                 if (($curlResult->getContentType() != '') && !strstr(strtolower($curlResult->getContentType()), 'html')) {
149                         return $siteinfo;
150                 }
151
152                 $header = $curlResult->getHeader();
153                 $body = $curlResult->getBody();
154
155                 if ($do_oembed) {
156                         $oembed_data = OEmbed::fetchURL($url);
157
158                         if (!empty($oembed_data->type)) {
159                                 if (!in_array($oembed_data->type, ['error', 'rich', ''])) {
160                                         $siteinfo['type'] = $oembed_data->type;
161                                 }
162
163                                 // See https://github.com/friendica/friendica/pull/5763#discussion_r217913178
164                                 if ($siteinfo['type'] != 'photo') {
165                                         if (isset($oembed_data->title)) {
166                                                 $siteinfo['title'] = trim($oembed_data->title);
167                                         }
168                                         if (isset($oembed_data->description)) {
169                                                 $siteinfo['text'] = trim($oembed_data->description);
170                                         }
171                                         if (isset($oembed_data->thumbnail_url)) {
172                                                 $siteinfo['image'] = $oembed_data->thumbnail_url;
173                                         }
174                                 }
175                         }
176                 }
177
178                 // Fetch the first mentioned charset. Can be in body or header
179                 $charset = '';
180                 if (preg_match('/charset=(.*?)[\'"\s\n]/', $header, $matches)) {
181                         $charset = trim(trim(trim(array_pop($matches)), ';,'));
182                 }
183
184                 if ($charset && strtoupper($charset) != 'UTF-8') {
185                         // See https://github.com/friendica/friendica/issues/5470#issuecomment-418351211
186                         $charset = str_ireplace('latin-1', 'latin1', $charset);
187
188                         Logger::log('detected charset ' . $charset, Logger::DEBUG);
189                         $body = iconv($charset, 'UTF-8//TRANSLIT', $body);
190                 }
191
192                 $body = mb_convert_encoding($body, 'HTML-ENTITIES', 'UTF-8');
193
194                 $doc = new DOMDocument();
195                 @$doc->loadHTML($body);
196
197                 XML::deleteNode($doc, 'style');
198                 XML::deleteNode($doc, 'script');
199                 XML::deleteNode($doc, 'option');
200                 XML::deleteNode($doc, 'h1');
201                 XML::deleteNode($doc, 'h2');
202                 XML::deleteNode($doc, 'h3');
203                 XML::deleteNode($doc, 'h4');
204                 XML::deleteNode($doc, 'h5');
205                 XML::deleteNode($doc, 'h6');
206                 XML::deleteNode($doc, 'ol');
207                 XML::deleteNode($doc, 'ul');
208
209                 $xpath = new DOMXPath($doc);
210
211                 $list = $xpath->query('//meta[@content]');
212                 foreach ($list as $node) {
213                         $meta_tag = [];
214                         if ($node->attributes->length) {
215                                 foreach ($node->attributes as $attribute) {
216                                         $meta_tag[$attribute->name] = $attribute->value;
217                                 }
218                         }
219
220                         if (@$meta_tag['http-equiv'] == 'refresh') {
221                                 $path = $meta_tag['content'];
222                                 $pathinfo = explode(';', $path);
223                                 $content = '';
224                                 foreach ($pathinfo as $value) {
225                                         if (substr(strtolower($value), 0, 4) == 'url=') {
226                                                 $content = substr($value, 4);
227                                         }
228                                 }
229                                 if ($content != '') {
230                                         $siteinfo = self::getSiteinfo($content, $no_guessing, $do_oembed, ++$count);
231                                         return $siteinfo;
232                                 }
233                         }
234                 }
235
236                 $list = $xpath->query('//title');
237                 if ($list->length > 0) {
238                         $siteinfo['title'] = trim($list->item(0)->nodeValue);
239                 }
240
241                 $list = $xpath->query('//meta[@name]');
242                 foreach ($list as $node) {
243                         $meta_tag = [];
244                         if ($node->attributes->length) {
245                                 foreach ($node->attributes as $attribute) {
246                                         $meta_tag[$attribute->name] = $attribute->value;
247                                 }
248                         }
249
250                         if (empty($meta_tag['content'])) {
251                                 continue;
252                         }
253
254                         $meta_tag['content'] = trim(html_entity_decode($meta_tag['content'], ENT_QUOTES, 'UTF-8'));
255
256                         switch (strtolower($meta_tag['name'])) {
257                                 case 'fulltitle':
258                                         $siteinfo['title'] = trim($meta_tag['content']);
259                                         break;
260                                 case 'description':
261                                         $siteinfo['text'] = trim($meta_tag['content']);
262                                         break;
263                                 case 'thumbnail':
264                                         $siteinfo['image'] = $meta_tag['content'];
265                                         break;
266                                 case 'twitter:image':
267                                         $siteinfo['image'] = $meta_tag['content'];
268                                         break;
269                                 case 'twitter:image:src':
270                                         $siteinfo['image'] = $meta_tag['content'];
271                                         break;
272                                 case 'twitter:card':
273                                         // Detect photo pages
274                                         if ($meta_tag['content'] == 'summary_large_image') {
275                                                 $siteinfo['type'] = 'photo';
276                                         }
277                                         break;
278                                 case 'twitter:description':
279                                         $siteinfo['text'] = trim($meta_tag['content']);
280                                         break;
281                                 case 'twitter:title':
282                                         $siteinfo['title'] = trim($meta_tag['content']);
283                                         break;
284                                 case 'dc.title':
285                                         $siteinfo['title'] = trim($meta_tag['content']);
286                                         break;
287                                 case 'dc.description':
288                                         $siteinfo['text'] = trim($meta_tag['content']);
289                                         break;
290                                 case 'keywords':
291                                         $keywords = explode(',', $meta_tag['content']);
292                                         break;
293                                 case 'news_keywords':
294                                         $keywords = explode(',', $meta_tag['content']);
295                                         break;
296                         }
297                 }
298
299                 if (isset($keywords)) {
300                         $siteinfo['keywords'] = [];
301                         foreach ($keywords as $keyword) {
302                                 if (!in_array(trim($keyword), $siteinfo['keywords'])) {
303                                         $siteinfo['keywords'][] = trim($keyword);
304                                 }
305                         }
306                 }
307
308                 $list = $xpath->query('//meta[@property]');
309                 foreach ($list as $node) {
310                         $meta_tag = [];
311                         if ($node->attributes->length) {
312                                 foreach ($node->attributes as $attribute) {
313                                         $meta_tag[$attribute->name] = $attribute->value;
314                                 }
315                         }
316
317                         if (!empty($meta_tag['content'])) {
318                                 $meta_tag['content'] = trim(html_entity_decode($meta_tag['content'], ENT_QUOTES, 'UTF-8'));
319
320                                 switch (strtolower($meta_tag['property'])) {
321                                         case 'og:image':
322                                                 $siteinfo['image'] = $meta_tag['content'];
323                                                 break;
324                                         case 'og:title':
325                                                 $siteinfo['title'] = trim($meta_tag['content']);
326                                                 break;
327                                         case 'og:description':
328                                                 $siteinfo['text'] = trim($meta_tag['content']);
329                                                 break;
330                                 }
331                         }
332                 }
333
334                 // Prevent to have a photo type without an image
335                 if ((empty($siteinfo['image']) || !empty($siteinfo['text'])) && ($siteinfo['type'] == 'photo')) {
336                         $siteinfo['type'] = 'link';
337                 }
338
339                 if (empty($siteinfo['image']) && !$no_guessing) {
340                         $list = $xpath->query('//img[@src]');
341                         foreach ($list as $node) {
342                                 $img_tag = [];
343                                 if ($node->attributes->length) {
344                                         foreach ($node->attributes as $attribute) {
345                                                 $img_tag[$attribute->name] = $attribute->value;
346                                         }
347                                 }
348
349                                 $src = self::completeUrl($img_tag['src'], $url);
350                                 $photodata = Images::getInfoFromURLCached($src);
351
352                                 if (($photodata) && ($photodata[0] > 150) && ($photodata[1] > 150)) {
353                                         if ($photodata[0] > 300) {
354                                                 $photodata[1] = round($photodata[1] * (300 / $photodata[0]));
355                                                 $photodata[0] = 300;
356                                         }
357                                         if ($photodata[1] > 300) {
358                                                 $photodata[0] = round($photodata[0] * (300 / $photodata[1]));
359                                                 $photodata[1] = 300;
360                                         }
361                                         $siteinfo['images'][] = [
362                                                 'src'    => $src,
363                                                 'width'  => $photodata[0],
364                                                 'height' => $photodata[1]
365                                         ];
366                                 }
367                         }
368                 } elseif (!empty($siteinfo['image'])) {
369                         $src = self::completeUrl($siteinfo['image'], $url);
370
371                         unset($siteinfo['image']);
372
373                         $photodata = Images::getInfoFromURLCached($src);
374
375                         if (($photodata) && ($photodata[0] > 10) && ($photodata[1] > 10)) {
376                                 $siteinfo['images'][] = ['src' => $src,
377                                         'width' => $photodata[0],
378                                         'height' => $photodata[1]];
379                         }
380                 }
381
382                 if ((@$siteinfo['text'] == '') && (@$siteinfo['title'] != '') && !$no_guessing) {
383                         $text = '';
384
385                         $list = $xpath->query('//div[@class="article"]');
386                         foreach ($list as $node) {
387                                 if (strlen($node->nodeValue) > 40) {
388                                         $text .= ' ' . trim($node->nodeValue);
389                                 }
390                         }
391
392                         if ($text == '') {
393                                 $list = $xpath->query('//div[@class="content"]');
394                                 foreach ($list as $node) {
395                                         if (strlen($node->nodeValue) > 40) {
396                                                 $text .= ' ' . trim($node->nodeValue);
397                                         }
398                                 }
399                         }
400
401                         // If none text was found then take the paragraph content
402                         if ($text == '') {
403                                 $list = $xpath->query('//p');
404                                 foreach ($list as $node) {
405                                         if (strlen($node->nodeValue) > 40) {
406                                                 $text .= ' ' . trim($node->nodeValue);
407                                         }
408                                 }
409                         }
410
411                         if ($text != '') {
412                                 $text = trim(str_replace(["\n", "\r"], [' ', ' '], $text));
413
414                                 while (strpos($text, '  ')) {
415                                         $text = trim(str_replace('  ', ' ', $text));
416                                 }
417
418                                 $siteinfo['text'] = trim(html_entity_decode(substr($text, 0, 350), ENT_QUOTES, 'UTF-8') . '...');
419                         }
420                 }
421
422                 Logger::log('Siteinfo for ' . $url . ' ' . print_r($siteinfo, true), Logger::DEBUG);
423
424                 Hook::callAll('getsiteinfo', $siteinfo);
425
426                 return $siteinfo;
427         }
428
429         /**
430          * @brief Convert tags from CSV to an array
431          *
432          * @param string $string Tags
433          * @return array with formatted Hashtags
434          */
435         public static function convertTagsToArray($string)
436         {
437                 $arr_tags = str_getcsv($string);
438                 if (count($arr_tags)) {
439                         // add the # sign to every tag
440                         array_walk($arr_tags, ["self", "arrAddHashes"]);
441
442                         return $arr_tags;
443                 }
444         }
445
446         /**
447          * @brief Add a hasht sign to a string
448          *
449          *  This method is used as callback function
450          *
451          * @param string $tag The pure tag name
452          * @param int    $k   Counter for internal use
453          * @return void
454          */
455         private static function arrAddHashes(&$tag, $k)
456         {
457                 $tag = "#" . $tag;
458         }
459
460         /**
461          * @brief Add a scheme to an url
462          *
463          * The src attribute of some html elements (e.g. images)
464          * can miss the scheme so we need to add the correct
465          * scheme
466          *
467          * @param string $url    The url which possibly does have
468          *                       a missing scheme (a link to an image)
469          * @param string $scheme The url with a correct scheme
470          *                       (e.g. the url from the webpage which does contain the image)
471          *
472          * @return string The url with a scheme
473          */
474         private static function completeUrl($url, $scheme)
475         {
476                 $urlarr = parse_url($url);
477
478                 // If the url does allready have an scheme
479                 // we can stop the process here
480                 if (isset($urlarr["scheme"])) {
481                         return($url);
482                 }
483
484                 $schemearr = parse_url($scheme);
485
486                 $complete = $schemearr["scheme"]."://".$schemearr["host"];
487
488                 if (!empty($schemearr["port"])) {
489                         $complete .= ":".$schemearr["port"];
490                 }
491
492                 if (!empty($urlarr["path"])) {
493                         if (strpos($urlarr["path"], "/") !== 0) {
494                                 $complete .= "/";
495                         }
496
497                         $complete .= $urlarr["path"];
498                 }
499
500                 if (!empty($urlarr["query"])) {
501                         $complete .= "?".$urlarr["query"];
502                 }
503
504                 if (!empty($urlarr["fragment"])) {
505                         $complete .= "#".$urlarr["fragment"];
506                 }
507
508                 return($complete);
509         }
510 }