| @@ -41,10 +41,13 @@ | ||
| 41 | 41 | if (!$src) { |
| 42 | 42 | return ''; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | + if (preg_match('#^https?://(?:[\w-]+\.)?youtube\.com/embed/([^?/]+)#i', $src, $ytMatch)) { | |
| 46 | + return self::bestYoutubeThumbnail($ytMatch[1]); | |
| 47 | + } | |
| 48 | + | |
| 45 | 49 | $providers = [ |
| 46 | - '#^https?://(?:[\w-]+\.)?youtube\.com/embed/([^?/]+).*$#i' => 'https://img.youtube.com/vi/$1/hqdefault.jpg', | |
| 47 | 50 | '#^https?://player\.vimeo\.com/video/([^?/]+).*$#i' => 'https://vumbnail.com/$1.jpg', |
| 48 | 51 | '#^https?://fast\.wistia\.net/embed/iframe/([^?/]+).*$#i' => 'https://fast.wistia.net/embed/medias/$1/swatch', |
| 49 | 52 | '#^https?://(?:www\.)?dailymotion\.com/(?:embed/video|player\.html\?video=)/?([^?/&]+).*$#i' => 'https://www.dailymotion.com/thumbnail/video/$1', |
| 50 | 53 | ]; |
| @@ -59,8 +62,42 @@ | ||
| 59 | 62 | $parsed = self::parse($src); |
| 60 | 63 | return (!is_wp_error($parsed) && !empty($parsed['image'])) ? $parsed['image'] : ''; |
| 61 | 64 | } |
| 62 | 65 | |
| 66 | + /** | |
| 67 | + * Pick the best YouTube thumbnail for a feed preview. | |
| 68 | + * | |
| 69 | + * Runs on the feed-save path, so it stays cheap: a single HEAD probe for | |
| 70 | + * the HD WebP frame (~30-56% smaller than JPG, sharp 16:9), falling back | |
| 71 | + * to hqdefault.jpg — the one universally present rung (maxres/sd and even | |
| 72 | + * hqdefault.webp 404 for non-HD or legacy uploads). The result is stored | |
| 73 | + * on the feed and cached, so the probe is paid once per video. | |
| 74 | + */ | |
| 75 | + protected static function bestYoutubeThumbnail($videoId) | |
| 76 | + { | |
| 77 | + $videoId = sanitize_text_field($videoId); | |
| 78 | + if (!$videoId) { | |
| 79 | + return ''; | |
| 80 | + } | |
| 81 | + | |
| 82 | + $fallback = 'https://img.youtube.com/vi/' . $videoId . '/hqdefault.jpg'; | |
| 83 | + | |
| 84 | + $cacheKey = 'fcom_yt_thumb_' . md5($videoId); | |
| 85 | + $cached = get_transient($cacheKey); | |
| 86 | + if ($cached !== false) { | |
| 87 | + return $cached; | |
| 88 | + } | |
| 89 | + | |
| 90 | + $maxRes = 'https://i.ytimg.com/vi_webp/' . $videoId . '/maxresdefault.webp'; | |
| 91 | + $response = wp_remote_head($maxRes, ['timeout' => 1.5, 'redirection' => 0]); | |
| 92 | + if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { | |
| 93 | + set_transient($cacheKey, $maxRes, WEEK_IN_SECONDS); | |
| 94 | + return $maxRes; | |
| 95 | + } | |
| 96 | + | |
| 97 | + return $fallback; | |
| 98 | + } | |
| 99 | + | |
| 63 | 100 | public function getOembed($url) |
| 64 | 101 | { |
| 65 | 102 | $data = (new \WP_oEmbed())->get_data($url, [ |
| 66 | 103 | 'discover' => false |
| @@ -71,20 +108,36 @@ | ||
| 71 | 108 | } |
| 72 | 109 | |
| 73 | 110 | $data = (array)$data; |
| 74 | 111 | |
| 112 | + $provider = strtolower(Arr::get($data, 'provider_name')); | |
| 113 | + | |
| 114 | + $image = Arr::get($data, 'thumbnail_url'); | |
| 115 | + if ($provider === 'youtube') { | |
| 116 | + $image = self::bestYoutubeThumbnail(self::getYoutubeVideoId($url)) ?: $image; | |
| 117 | + } | |
| 118 | + | |
| 75 | 119 | return array_filter([ |
| 76 | 120 | 'title' => Arr::get($data, 'title'), |
| 77 | 121 | 'author_name' => Arr::get($data, 'author_name'), |
| 78 | 122 | 'type' => 'oembed', |
| 79 | - 'provider' => strtolower(Arr::get($data, 'provider_name')), | |
| 123 | + 'provider' => $provider, | |
| 80 | 124 | 'content_type' => Arr::get($data, 'type'), |
| 81 | 125 | 'url' => $url, |
| 82 | 126 | 'html' => self::sanitizeOembedHtml(Arr::get($data, 'html')), |
| 83 | - 'image' => Arr::get($data, 'thumbnail_url'), | |
| 127 | + 'image' => $image, | |
| 84 | 128 | ]); |
| 85 | 129 | } |
| 86 | 130 | |
| 131 | + protected static function getYoutubeVideoId($url) | |
| 132 | + { | |
| 133 | + if (preg_match('#(?:youtu\.be/|youtube\.com/(?:embed/|v/|live/|shorts/|watch\?v=))([a-zA-Z0-9_-]+)#i', (string)$url, $match)) { | |
| 134 | + return $match[1]; | |
| 135 | + } | |
| 136 | + | |
| 137 | + return ''; | |
| 138 | + } | |
| 139 | + | |
| 87 | 140 | public static function sanitizeOembedHtml($html) |
| 88 | 141 | { |
| 89 | 142 | if (empty($html)) { |
| 90 | 143 | return $html; |
| @@ -104,8 +157,12 @@ | ||
| 104 | 157 | 'referrerpolicy' => true, |
| 105 | 158 | 'sandbox' => true, |
| 106 | 159 | ]; |
| 107 | 160 | } |
| 161 | + | |
| 162 | + // Strip <script>/<style> incl. contents; wp_kses keeps their inner text otherwise. | |
| 163 | + $html = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', '', $html); | |
| 164 | + $html = preg_replace('#<(script|style)\b[^>]*/?>#i', '', $html); | |
| 108 | 165 | |
| 109 | 166 | return wp_kses($html, $allowed, ['https']); |
| 110 | 167 | } |
| 111 | 168 | |