FaviconDownloader.php
176 lines
| 1 | <?php |
| 2 | |
| 3 | namespace IAWP\Favicon; |
| 4 | |
| 5 | use IAWP\Favicon\Converter\AbstractConverter; |
| 6 | /** @internal */ |
| 7 | class FaviconDownloader |
| 8 | { |
| 9 | private \IAWP\Favicon\Favicon $favicon; |
| 10 | public function __construct(\IAWP\Favicon\Favicon $favicon) |
| 11 | { |
| 12 | $this->favicon = $favicon; |
| 13 | } |
| 14 | public function download() : void |
| 15 | { |
| 16 | try { |
| 17 | $this->attempt_download(); |
| 18 | } catch (\Throwable $e) { |
| 19 | // |
| 20 | } |
| 21 | } |
| 22 | private function attempt_download() |
| 23 | { |
| 24 | if (!$this->has_required_php_extensions()) { |
| 25 | return; |
| 26 | } |
| 27 | if ($this->favicon->exists()) { |
| 28 | return; |
| 29 | } |
| 30 | $url = \sanitize_url($this->favicon->domain); |
| 31 | $content = $this->fetch($url); |
| 32 | if ($content === null) { |
| 33 | return; |
| 34 | } |
| 35 | try { |
| 36 | $favicon_url = $this->extractFaviconUrl($content, $url); |
| 37 | } catch (\Throwable $e) { |
| 38 | return; |
| 39 | } |
| 40 | if ($favicon_url === null) { |
| 41 | return; |
| 42 | } |
| 43 | $path = \IAWPSCOPED\iawp_upload_path_to('iawp-favicons/' . $this->favicon->file_name()); |
| 44 | $blob = $this->fetch($favicon_url); |
| 45 | if (!$blob) { |
| 46 | return; |
| 47 | } |
| 48 | $converter = AbstractConverter::make($blob, $path); |
| 49 | if (!$converter) { |
| 50 | return; |
| 51 | } |
| 52 | $converter->save(); |
| 53 | } |
| 54 | private function fetch(string $url) : ?string |
| 55 | { |
| 56 | try { |
| 57 | $handle = \curl_init(); |
| 58 | \curl_setopt($handle, \CURLOPT_URL, $url); |
| 59 | \curl_setopt($handle, \CURLOPT_RETURNTRANSFER, 1); |
| 60 | \curl_setopt($handle, \CURLOPT_FOLLOWLOCATION, \true); |
| 61 | \curl_setopt($handle, \CURLOPT_MAXREDIRS, 10); |
| 62 | \curl_setopt($handle, \CURLOPT_TIMEOUT, 10); |
| 63 | \curl_setopt($handle, \CURLOPT_ENCODING, ''); |
| 64 | // Handle compression automatically |
| 65 | // Mimic a real browser to bypass Cloudflare and other bot protections |
| 66 | \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'); |
| 67 | $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']; |
| 68 | \curl_setopt($handle, \CURLOPT_HTTPHEADER, $headers); |
| 69 | $content = \curl_exec($handle); |
| 70 | $httpCode = \curl_getinfo($handle, \CURLINFO_HTTP_CODE); |
| 71 | \curl_close($handle); |
| 72 | if ($httpCode >= 400) { |
| 73 | return null; |
| 74 | } |
| 75 | return $content !== \false ? $content : null; |
| 76 | } catch (\Throwable $e) { |
| 77 | return null; |
| 78 | } |
| 79 | } |
| 80 | private function extractFaviconUrl(string $html, string $baseUrl) : ?string |
| 81 | { |
| 82 | $dom = new \DOMDocument(); |
| 83 | @$dom->loadHTML($html); |
| 84 | // Suppress HTML parsing errors |
| 85 | $xpath = new \DOMXPath($dom); |
| 86 | // XPath Query: Find link tags in <head> with 'icon' in the 'rel' attribute |
| 87 | $query = "//head/link[contains(translate(@rel, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'icon')]"; |
| 88 | $linkNodes = $xpath->query($query); |
| 89 | if ($linkNodes->length > 0) { |
| 90 | $bestIcon = null; |
| 91 | foreach ($linkNodes as $node) { |
| 92 | $rel = \strtolower($node->getAttribute('rel')); |
| 93 | $href = $node->getAttribute('href'); |
| 94 | $sizes = $node->getAttribute('sizes'); |
| 95 | $score = 0; |
| 96 | // Simple rel categorization |
| 97 | if (\strpos($rel, 'shortcut') !== \false) { |
| 98 | $rel = 'shortcut icon'; |
| 99 | } elseif (\strpos($rel, 'apple') !== \false) { |
| 100 | $rel = 'apple-touch-icon'; |
| 101 | if (!$sizes) { |
| 102 | $score = 180 * 180; |
| 103 | } |
| 104 | } elseif (\strpos($rel, 'icon') !== \false) { |
| 105 | $rel = 'icon'; |
| 106 | } else { |
| 107 | continue; |
| 108 | } |
| 109 | if ($sizes) { |
| 110 | if (\strtolower($sizes) === 'any') { |
| 111 | $score = \PHP_INT_MAX; |
| 112 | } else { |
| 113 | $dimensions = \explode(' ', $sizes); |
| 114 | foreach ($dimensions as $dim) { |
| 115 | $parts = \explode('x', \strtolower($dim)); |
| 116 | if (\count($parts) === 2) { |
| 117 | $w = (int) $parts[0]; |
| 118 | $h = (int) $parts[1]; |
| 119 | $area = $w * $h; |
| 120 | if ($area > $score) { |
| 121 | $score = $area; |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | $candidate = ['href' => $href, 'rel' => $rel, 'score' => $score]; |
| 128 | if ($bestIcon === null) { |
| 129 | $bestIcon = $candidate; |
| 130 | continue; |
| 131 | } |
| 132 | // Prioritize by score (size) |
| 133 | if ($candidate['score'] > $bestIcon['score']) { |
| 134 | $bestIcon = $candidate; |
| 135 | } elseif ($candidate['score'] === $bestIcon['score']) { |
| 136 | // Tie-breaker: Priority based on rel type |
| 137 | $priorities = ['apple-touch-icon' => 3, 'icon' => 2, 'shortcut icon' => 1]; |
| 138 | $currentP = $priorities[$candidate['rel']] ?? 0; |
| 139 | $bestP = $priorities[$bestIcon['rel']] ?? 0; |
| 140 | if ($currentP > $bestP) { |
| 141 | $bestIcon = $candidate; |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | if ($bestIcon) { |
| 146 | $relativeUrl = $bestIcon['href']; |
| 147 | // Construct the absolute URL |
| 148 | if (\strpos($relativeUrl, '//') === 0) { |
| 149 | return "https:{$relativeUrl}"; |
| 150 | } elseif (\strpos($relativeUrl, 'http') === 0) { |
| 151 | return $relativeUrl; |
| 152 | } else { |
| 153 | return \rtrim($baseUrl, '/') . '/' . \ltrim($relativeUrl, '/'); |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | // 4. Final Fallback: Check root /favicon.ico since DOM parsing failed |
| 158 | // We use the fetch method to gain the benefits of browser-mimicking headers |
| 159 | $standardIco = \rtrim($baseUrl, '/') . "/favicon.ico"; |
| 160 | $icoContent = $this->fetch($standardIco); |
| 161 | // Ensure we got content and it's not HTML (which would indicate a challenge page or 404 page) |
| 162 | if ($icoContent !== null && \strpos($icoContent, '<html') === \false && \strpos($icoContent, '<!DOCTYPE') === \false) { |
| 163 | return $standardIco; |
| 164 | } |
| 165 | return null; |
| 166 | } |
| 167 | private function has_required_php_extensions() : bool |
| 168 | { |
| 169 | return \extension_loaded('curl') && (\extension_loaded('gd') || \extension_loaded('imagick')); |
| 170 | } |
| 171 | public static function for(\IAWP\Favicon\Favicon $favicon) : self |
| 172 | { |
| 173 | return new self($favicon); |
| 174 | } |
| 175 | } |
| 176 |