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