108 lines
2.9 KiB
PHP
108 lines
2.9 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace App\Support;
|
||
|
|
|
||
|
|
use Illuminate\Http\Client\Response;
|
||
|
|
use Illuminate\Support\Facades\Http;
|
||
|
|
use Illuminate\Support\Str;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Finds a real, working favicon/icon URL for a site. `{domain}/favicon.ico`
|
||
|
|
* is a guess, not a guarantee -- plenty of sites serve their icon from a
|
||
|
|
* hashed asset path, an SVG, or declare it only via a <link rel="icon">
|
||
|
|
* tag. This tries progressively more reliable sources before giving up.
|
||
|
|
*/
|
||
|
|
class FaviconResolver
|
||
|
|
{
|
||
|
|
public function resolve(string $siteUrl): ?string
|
||
|
|
{
|
||
|
|
return $this->fromWellKnownPath($siteUrl)
|
||
|
|
?? $this->fromHtmlLinkTag($siteUrl)
|
||
|
|
?? $this->fromGoogleFallback($siteUrl);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function fromWellKnownPath(string $siteUrl): ?string
|
||
|
|
{
|
||
|
|
$candidate = rtrim($siteUrl, '/').'/favicon.ico';
|
||
|
|
|
||
|
|
return $this->isImage($candidate) ? $candidate : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function fromHtmlLinkTag(string $siteUrl): ?string
|
||
|
|
{
|
||
|
|
$response = $this->safeGet($siteUrl);
|
||
|
|
|
||
|
|
if (! $response || ! $response->successful()) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (! preg_match_all(
|
||
|
|
'/<link\b[^>]*rel=["\'](?:shortcut icon|icon|apple-touch-icon)["\'][^>]*>/i',
|
||
|
|
$response->body(),
|
||
|
|
$tags
|
||
|
|
)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach ($tags[0] as $tag) {
|
||
|
|
if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $hrefMatch)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$absolute = $this->toAbsoluteUrl($hrefMatch[1], $siteUrl);
|
||
|
|
|
||
|
|
if ($absolute && $this->isImage($absolute)) {
|
||
|
|
return $absolute;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Google's favicon service resolves almost anything (it does its own
|
||
|
|
* multi-source lookup), making it a reliable last resort rather than
|
||
|
|
* leaving the tool with no logo at all.
|
||
|
|
*/
|
||
|
|
private function fromGoogleFallback(string $siteUrl): string
|
||
|
|
{
|
||
|
|
return 'https://www.google.com/s2/favicons?sz=128&domain_url='.urlencode($siteUrl);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function toAbsoluteUrl(string $href, string $base): ?string
|
||
|
|
{
|
||
|
|
if (Str::startsWith($href, ['http://', 'https://'])) {
|
||
|
|
return $href;
|
||
|
|
}
|
||
|
|
|
||
|
|
$parsed = parse_url($base);
|
||
|
|
|
||
|
|
if (! $parsed || ! isset($parsed['scheme'], $parsed['host'])) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Str::startsWith($href, '//')) {
|
||
|
|
return "{$parsed['scheme']}:{$href}";
|
||
|
|
}
|
||
|
|
|
||
|
|
return "{$parsed['scheme']}://{$parsed['host']}/".ltrim($href, '/');
|
||
|
|
}
|
||
|
|
|
||
|
|
private function isImage(string $url): bool
|
||
|
|
{
|
||
|
|
$response = $this->safeGet($url);
|
||
|
|
|
||
|
|
return (bool) $response?->successful()
|
||
|
|
&& str_starts_with((string) $response->header('Content-Type'), 'image/');
|
||
|
|
}
|
||
|
|
|
||
|
|
private function safeGet(string $url): ?Response
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
return Http::timeout(6)->get($url);
|
||
|
|
} catch (\Throwable) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|