. * */ namespace Friendica\Util; use DOMDocument; use DOMXPath; use Friendica\Content\OEmbed; use Friendica\Core\Hook; use Friendica\Core\Logger; use Friendica\Database\Database; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Network\HTTPClient\Client\HttpClientAccept; use Friendica\Network\HTTPException; use Friendica\Network\HTTPClient\Client\HttpClientOptions; /** * Get information about a given URL * * Class with methods for extracting certain content from an url */ class ParseUrl { const DEFAULT_EXPIRATION_FAILURE = 'now + 1 day'; const DEFAULT_EXPIRATION_SUCCESS = 'now + 3 months'; /** * Maximum number of characters for the description */ const MAX_DESC_COUNT = 250; /** * Minimum number of characters for the description */ const MIN_DESC_COUNT = 100; /** * Fetch the content type of the given url * @param string $url URL of the page * @param string $accept content-type to accept * @return array content type */ public static function getContentType(string $url, string $accept = HttpClientAccept::DEFAULT) { $curlResult = DI::httpClient()->head($url, $accept); // Workaround for systems that can't handle a HEAD request if (!$curlResult->isSuccess() && ($curlResult->getReturnCode() == 405)) { $curlResult = DI::httpClient()->get($url, $accept, [HttpClientOptions::CONTENT_LENGTH => 1000000]); } if (!$curlResult->isSuccess()) { return []; } $contenttype = $curlResult->getHeader('Content-Type')[0] ?? ''; if (empty($contenttype)) { return ['application', 'octet-stream']; } return explode('/', current(explode(';', $contenttype))); } /** * Search for chached embeddable data of an url otherwise fetch it * * @param string $url The url of the page which should be scraped * @param bool $do_oembed The false option is used by the function fetch_oembed() * to avoid endless loops * * @return array which contains needed data for embedding * string 'url' => The url of the parsed page * string 'type' => Content type * string 'title' => (optional) The title of the content * string 'text' => (optional) The description for the content * string 'image' => (optional) A preview image of the content * array 'images' => (optional) Array of preview pictures * string 'keywords' => (optional) The tags which belong to the content * * @throws HTTPException\InternalServerErrorException * @see ParseUrl::getSiteinfo() for more information about scraping * embeddable content */ public static function getSiteinfoCached($url, $do_oembed = true): array { if (empty($url)) { return [ 'url' => '', 'type' => 'error', ]; } $urlHash = hash('sha256', $url); $parsed_url = DBA::selectFirst('parsed_url', ['content'], ['url_hash' => $urlHash, 'oembed' => $do_oembed] ); if (!empty($parsed_url['content'])) { $data = unserialize($parsed_url['content']); return $data; } $data = self::getSiteinfo($url, $do_oembed); $expires = $data['expires']; unset($data['expires']); DI::dba()->insert( 'parsed_url', [ 'url_hash' => $urlHash, 'oembed' => $do_oembed, 'url' => $url, 'content' => serialize($data), 'created' => DateTimeFormat::utcNow(), 'expires' => $expires, ], Database::INSERT_UPDATE ); return $data; } /** * Parse a page for embeddable content information * * This method parses to url for meta data which can be used to embed * the content. If available it prioritizes Open Graph meta tags. * If this is not available it uses the twitter cards meta tags. * As fallback it uses standard html elements with meta informations * like \Awesome Title\ or * \ * * @param string $url The url of the page which should be scraped * @param bool $do_oembed The false option is used by the function fetch_oembed() * to avoid endless loops * @param int $count Internal counter to avoid endless loops * * @return array which contains needed data for embedding * string 'url' => The url of the parsed page * string 'type' => Content type (error, link, photo, image, audio, video) * string 'title' => (optional) The title of the content * string 'text' => (optional) The description for the content * string 'image' => (optional) A preview image of the content * array 'images' => (optional) Array of preview pictures * string 'keywords' => (optional) The tags which belong to the content * * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @todo https://developers.google.com/+/plugins/snippet/ * @verbatim * * * * * *

Shiny Trinket

* *

Shiny trinkets are shiny.

* * @endverbatim */ public static function getSiteinfo($url, $do_oembed = true, $count = 1) { if (empty($url)) { return [ 'url' => '', 'type' => 'error', ]; } // Check if the URL does contain a scheme $scheme = parse_url($url, PHP_URL_SCHEME); if ($scheme == '') { $url = 'http://' . ltrim($url, '/'); } $url = trim($url, "'\""); $url = Network::stripTrackingQueryParams($url); $siteinfo = [ 'url' => $url, 'type' => 'link', 'expires' => DateTimeFormat::utc(self::DEFAULT_EXPIRATION_FAILURE), ]; if ($count > 10) { Logger::notice('Endless loop detected', ['url' => $url]); return $siteinfo; } $type = self::getContentType($url); Logger::info('Got content-type', ['content-type' => $type, 'url' => $url]); if (!empty($type) && in_array($type[0], ['image', 'video', 'audio'])) { $siteinfo['type'] = $type[0]; return $siteinfo; } if ((count($type) >= 2) && (($type[0] != 'text') || ($type[1] != 'html'))) { Logger::info('Unparseable content-type, quitting here, ', ['content-type' => $type, 'url' => $url]); return $siteinfo; } $curlResult = DI::httpClient()->get($url, HttpClientAccept::HTML, [HttpClientOptions::CONTENT_LENGTH => 1000000]); if (!$curlResult->isSuccess() || empty($curlResult->getBody())) { Logger::info('Empty body or error when fetching', ['url' => $url, 'success' => $curlResult->isSuccess(), 'code' => $curlResult->getReturnCode()]); return $siteinfo; } $siteinfo['expires'] = DateTimeFormat::utc(self::DEFAULT_EXPIRATION_SUCCESS); if ($cacheControlHeader = $curlResult->getHeader('Cache-Control')[0] ?? '') { if (preg_match('/max-age=([0-9]+)/i', $cacheControlHeader, $matches)) { $maxAge = max(86400, (int)array_pop($matches)); $siteinfo['expires'] = DateTimeFormat::utc("now + $maxAge seconds"); } } $body = $curlResult->getBody(); if ($do_oembed) { $oembed_data = OEmbed::fetchURL($url, false, false); if (!empty($oembed_data->type)) { if (!in_array($oembed_data->type, ['error', 'rich', 'image', 'video', 'audio', ''])) { $siteinfo['type'] = $oembed_data->type; } // See https://github.com/friendica/friendica/pull/5763#discussion_r217913178 if ($siteinfo['type'] != 'photo') { if (!empty($oembed_data->title)) { $siteinfo['title'] = trim($oembed_data->title); } if (!empty($oembed_data->description)) { $siteinfo['text'] = trim($oembed_data->description); } if (!empty($oembed_data->author_name)) { $siteinfo['author_name'] = trim($oembed_data->author_name); } if (!empty($oembed_data->author_url)) { $siteinfo['author_url'] = trim($oembed_data->author_url); } if (!empty($oembed_data->provider_name)) { $siteinfo['publisher_name'] = trim($oembed_data->provider_name); } if (!empty($oembed_data->provider_url)) { $siteinfo['publisher_url'] = trim($oembed_data->provider_url); } if (!empty($oembed_data->thumbnail_url)) { $siteinfo['image'] = $oembed_data->thumbnail_url; } } } } $charset = ''; // Look for a charset, first in headers // Expected form: Content-Type: text/html; charset=ISO-8859-4 if (preg_match('/charset=([a-z0-9-_.\/]+)/i', $curlResult->getContentType(), $matches)) { $charset = trim(trim(trim(array_pop($matches)), ';,')); } // Then in body that gets precedence // Expected forms: // - // - // - // - // We escape