FaviconDownloader.php
219 lines
| 1 | <?php |
| 2 | |
| 3 | namespace IAWP\Favicon; |
| 4 | |
| 5 | /** @internal */ |
| 6 | class FaviconDownloader |
| 7 | { |
| 8 | private \IAWP\Favicon\Favicon $favicon; |
| 9 | public function __construct(\IAWP\Favicon\Favicon $favicon) |
| 10 | { |
| 11 | $this->favicon = $favicon; |
| 12 | } |
| 13 | public function download() : void |
| 14 | { |
| 15 | try { |
| 16 | $this->attempt_download(); |
| 17 | } catch (\Throwable $e) { |
| 18 | // |
| 19 | } |
| 20 | } |
| 21 | private function attempt_download() |
| 22 | { |
| 23 | if (!$this->has_required_php_extensions()) { |
| 24 | return; |
| 25 | } |
| 26 | if ($this->favicon->exists()) { |
| 27 | return; |
| 28 | } |
| 29 | $url = \sanitize_url($this->favicon->domain); |
| 30 | $content = $this->fetch($url); |
| 31 | if ($content === null) { |
| 32 | return; |
| 33 | } |
| 34 | try { |
| 35 | $favicon_url = $this->extractFaviconUrl($content, $url); |
| 36 | } catch (\Throwable $e) { |
| 37 | return; |
| 38 | } |
| 39 | if ($favicon_url === null) { |
| 40 | return; |
| 41 | } |
| 42 | $save_path = \IAWPSCOPED\iawp_upload_path_to('iawp-favicons/' . $this->favicon->file_name()); |
| 43 | $this->convertFaviconToPng($favicon_url, $save_path); |
| 44 | } |
| 45 | private function fetch(string $url) : ?string |
| 46 | { |
| 47 | if (!\extension_loaded('curl')) { |
| 48 | return null; |
| 49 | } |
| 50 | $handle = \curl_init(); |
| 51 | \curl_setopt($handle, \CURLOPT_URL, $url); |
| 52 | \curl_setopt($handle, \CURLOPT_RETURNTRANSFER, 1); |
| 53 | \curl_setopt($handle, \CURLOPT_FOLLOWLOCATION, \true); |
| 54 | \curl_setopt($handle, \CURLOPT_MAXREDIRS, 10); |
| 55 | \curl_setopt($handle, \CURLOPT_TIMEOUT, 10); |
| 56 | \curl_setopt($handle, \CURLOPT_ENCODING, ''); |
| 57 | // Handle compression automatically |
| 58 | // Mimic a real browser to bypass Cloudflare and other bot protections |
| 59 | \curl_setopt($handle, \CURLOPT_USERAGENT, 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'); |
| 60 | $headers = ['Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language: en-US,en;q=0.9', 'Upgrade-Insecure-Requests: 1', 'Sec-Fetch-Dest: document', 'Sec-Fetch-Mode: navigate', 'Sec-Fetch-Site: none', 'Sec-Fetch-User: ?1']; |
| 61 | \curl_setopt($handle, \CURLOPT_HTTPHEADER, $headers); |
| 62 | $content = \curl_exec($handle); |
| 63 | $httpCode = \curl_getinfo($handle, \CURLINFO_HTTP_CODE); |
| 64 | \curl_close($handle); |
| 65 | if ($httpCode >= 400) { |
| 66 | return null; |
| 67 | } |
| 68 | return $content !== \false ? $content : null; |
| 69 | } |
| 70 | private function convertFaviconToPng(string $faviconUrl, string $savePath) : bool |
| 71 | { |
| 72 | // ... Imagick extension check ... |
| 73 | try { |
| 74 | $imagick = new \Imagick(); |
| 75 | $blob = $this->fetch($faviconUrl); |
| 76 | if ($blob === null) { |
| 77 | return \false; |
| 78 | } |
| 79 | // $imagick->setResolution(96, 96); |
| 80 | try { |
| 81 | $imagick->readImageBlob($blob); |
| 82 | } catch (\Throwable $e) { |
| 83 | // Retry with ICO format if initial read fails (common for some ICO files) |
| 84 | $imagick = new \Imagick(); |
| 85 | $imagick->setFormat('ico'); |
| 86 | $imagick->readImageBlob($blob); |
| 87 | } |
| 88 | // Handle multi-frame files (like ICO) by selecting the best frame |
| 89 | if ($imagick->getNumberImages() > 1) { |
| 90 | $bestIndex = 0; |
| 91 | $maxArea = 0; |
| 92 | $numImages = $imagick->getNumberImages(); |
| 93 | for ($i = 0; $i < $numImages; $i++) { |
| 94 | $imagick->setIteratorIndex($i); |
| 95 | $area = $imagick->getImageWidth() * $imagick->getImageHeight(); |
| 96 | if ($area > $maxArea) { |
| 97 | $maxArea = $area; |
| 98 | $bestIndex = $i; |
| 99 | } |
| 100 | } |
| 101 | $imagick->setIteratorIndex($bestIndex); |
| 102 | $image = $imagick->getImage(); |
| 103 | $imagick->clear(); |
| 104 | $imagick = $image; |
| 105 | } |
| 106 | // 3. Set the output format to PNG. The rasterized SVG is saved as PNG. |
| 107 | $imagick->setImageFormat('png'); |
| 108 | $max_size = 48; |
| 109 | if ($imagick->getImageWidth() > $max_size) { |
| 110 | $imagick->thumbnailImage($max_size, 0); |
| 111 | } |
| 112 | if ($imagick->getImageHeight() > $max_size) { |
| 113 | $imagick->thumbnailImage(0, $max_size); |
| 114 | } |
| 115 | $thumbnail_size = \min($imagick->getImageWidth(), $imagick->getImageHeight()); |
| 116 | $imagick->cropThumbnailImage($thumbnail_size, $thumbnail_size); |
| 117 | $imagick->writeImage($savePath); |
| 118 | return \true; |
| 119 | } catch (\Throwable $e) { |
| 120 | return \false; |
| 121 | } |
| 122 | } |
| 123 | private function extractFaviconUrl(string $html, string $baseUrl) : ?string |
| 124 | { |
| 125 | $dom = new \DOMDocument(); |
| 126 | @$dom->loadHTML($html); |
| 127 | // Suppress HTML parsing errors |
| 128 | $xpath = new \DOMXPath($dom); |
| 129 | // XPath Query: Find link tags in <head> with 'icon' in the 'rel' attribute |
| 130 | $query = "//head/link[contains(translate(@rel, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'icon')]"; |
| 131 | $linkNodes = $xpath->query($query); |
| 132 | if ($linkNodes->length > 0) { |
| 133 | $bestIcon = null; |
| 134 | foreach ($linkNodes as $node) { |
| 135 | $rel = \strtolower($node->getAttribute('rel')); |
| 136 | $href = $node->getAttribute('href'); |
| 137 | $sizes = $node->getAttribute('sizes'); |
| 138 | $score = 0; |
| 139 | // Simple rel categorization |
| 140 | if (\strpos($rel, 'shortcut') !== \false) { |
| 141 | $rel = 'shortcut icon'; |
| 142 | } elseif (\strpos($rel, 'apple') !== \false) { |
| 143 | $rel = 'apple-touch-icon'; |
| 144 | if (!$sizes) { |
| 145 | $score = 180 * 180; |
| 146 | } |
| 147 | } elseif (\strpos($rel, 'icon') !== \false) { |
| 148 | $rel = 'icon'; |
| 149 | } else { |
| 150 | continue; |
| 151 | } |
| 152 | if ($sizes) { |
| 153 | if (\strtolower($sizes) === 'any') { |
| 154 | $score = \PHP_INT_MAX; |
| 155 | } else { |
| 156 | $dimensions = \explode(' ', $sizes); |
| 157 | foreach ($dimensions as $dim) { |
| 158 | $parts = \explode('x', \strtolower($dim)); |
| 159 | if (\count($parts) === 2) { |
| 160 | $w = (int) $parts[0]; |
| 161 | $h = (int) $parts[1]; |
| 162 | $area = $w * $h; |
| 163 | if ($area > $score) { |
| 164 | $score = $area; |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | $candidate = ['href' => $href, 'rel' => $rel, 'score' => $score]; |
| 171 | if ($bestIcon === null) { |
| 172 | $bestIcon = $candidate; |
| 173 | continue; |
| 174 | } |
| 175 | // Prioritize by score (size) |
| 176 | if ($candidate['score'] > $bestIcon['score']) { |
| 177 | $bestIcon = $candidate; |
| 178 | } elseif ($candidate['score'] === $bestIcon['score']) { |
| 179 | // Tie-breaker: Priority based on rel type |
| 180 | $priorities = ['apple-touch-icon' => 3, 'icon' => 2, 'shortcut icon' => 1]; |
| 181 | $currentP = $priorities[$candidate['rel']] ?? 0; |
| 182 | $bestP = $priorities[$bestIcon['rel']] ?? 0; |
| 183 | if ($currentP > $bestP) { |
| 184 | $bestIcon = $candidate; |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | if ($bestIcon) { |
| 189 | $relativeUrl = $bestIcon['href']; |
| 190 | // Construct the absolute URL |
| 191 | if (\strpos($relativeUrl, '//') === 0) { |
| 192 | return "https:{$relativeUrl}"; |
| 193 | } elseif (\strpos($relativeUrl, 'http') === 0) { |
| 194 | return $relativeUrl; |
| 195 | } else { |
| 196 | return \rtrim($baseUrl, '/') . '/' . \ltrim($relativeUrl, '/'); |
| 197 | } |
| 198 | } |
| 199 | } |
| 200 | // 4. Final Fallback: Check root /favicon.ico since DOM parsing failed |
| 201 | // We use the fetch method to gain the benefits of browser-mimicking headers |
| 202 | $standardIco = \rtrim($baseUrl, '/') . "/favicon.ico"; |
| 203 | $icoContent = $this->fetch($standardIco); |
| 204 | // Ensure we got content and it's not HTML (which would indicate a challenge page or 404 page) |
| 205 | if ($icoContent !== null && \strpos($icoContent, '<html') === \false && \strpos($icoContent, '<!DOCTYPE') === \false) { |
| 206 | return $standardIco; |
| 207 | } |
| 208 | return null; |
| 209 | } |
| 210 | private function has_required_php_extensions() : bool |
| 211 | { |
| 212 | return \extension_loaded('curl') && \extension_loaded('imagick'); |
| 213 | } |
| 214 | public static function for(\IAWP\Favicon\Favicon $favicon) : self |
| 215 | { |
| 216 | return new self($favicon); |
| 217 | } |
| 218 | } |
| 219 |