→ 第三方兜底
* 抓取顺序(可靠性从高到低、对网络环境最友好):
* 1) 目标站点根路径 /favicon.ico(绝大多数站点自带,不依赖第三方)
* 2) 抓取首页 HTML,解析 指示的图标
* 3) 第三方兜底(icon.horse / Google,国内常被墙/慢,仅作保底)
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
* 输出由 BaseTrait::serveFile / serveDefault 统一处理。
*/
trait FaviconTrait
{
/**
* 本地 favicon 代理 + 缓存
* 浏览器统一请求 /haonav/favicon.html?url=xxx,由服务端抓取并缓存到
* public/static/haonav/favicons/,避免直接依赖第三方服务、提升加载速度。
*/
public function favicon()
{
$url = $this->request->param('url', '');
$host = $url ? parse_url($url, PHP_URL_HOST) : '';
if (! $host) {
return $this->serveDefault();
}
$root = app()->getRootPath() . 'public/static/haonav/favicons/';
if (! is_dir($root)) {
@mkdir($root, 0755, true);
}
$key = md5($host);
// 失败负缓存:24 小时内不再尝试远程抓取,直接返回默认图
$failFile = $root . $key . '.fail';
if (is_file($failFile) && (time() - filemtime($failFile)) < 86400) {
return $this->serveDefault();
}
// 已缓存则直接返回
foreach (['png', 'ico', 'jpg', 'jpeg', 'svg', 'webp', 'gif'] as $ext) {
if (is_file($root . $key . '.' . $ext)) {
return $this->serveFile($root . $key . '.' . $ext);
}
}
$img = $this->fetchFavicon($host);
if ($img) {
$path = $root . $key . '.' . $img['ext'];
@file_put_contents($path, $img['data']);
return $this->serveFile($path);
}
@touch($failFile);
return $this->serveDefault();
}
/**
* 远程抓取 favicon。
* 任一步成功即返回 ['ext'=>, 'data'=>],全部失败返回 false。
*/
protected function fetchFavicon(string $host)
{
if (! class_exists(\GuzzleHttp\Client::class)) {
return false;
}
$ua = ['Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'];
$client = new \GuzzleHttp\Client(['timeout' => 4, 'verify' => false]);
// 1) 站点自有 /favicon.ico(先 https 后 http)
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/favicon.ico', ['headers' => $ua]);
if ($resp->getStatusCode() === 200) {
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$body = (string)$resp->getBody();
// 服务器可能把 404 页面当 html 返回,需排除
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 2) 抓首页 HTML,解析 拿到真实图标地址
foreach (['https://' . $host, 'http://' . $host] as $base) {
try {
$resp = $client->get($base . '/', ['headers' => $ua]);
if ($resp->getStatusCode() !== 200) {
continue;
}
$html = (string)$resp->getBody();
$icon = $this->parseIconHref($html, $base);
if ($icon) {
$resp2 = $client->get($icon, ['headers' => $ua]);
if ($resp2->getStatusCode() === 200) {
$ct = strtolower($resp2->getHeaderLine('Content-Type'));
$body = (string)$resp2->getBody();
if (strlen($body) >= 32 && stripos($ct, 'html') === false) {
return ['ext' => $this->extFromCt($ct, 'ico'), 'data' => $body];
}
}
}
} catch (\Throwable $e) {
// 继续尝试
}
}
// 3) 第三方兜底(可能被墙/慢,仅保底)
$sources = [
'https://www.google.com/s2/favicons?domain=' . $host . '&sz=64',
'https://icon.horse/icon/' . $host,
];
foreach ($sources as $u) {
try {
$resp = $client->get($u, ['headers' => $ua]);
$body = (string)$resp->getBody();
if (strlen($body) < 32) {
continue;
}
$ct = strtolower($resp->getHeaderLine('Content-Type'));
$ext = 'png';
if (stripos($ct, 'svg') !== false) {
$ext = 'svg';
} elseif (stripos($ct, 'ico') !== false) {
$ext = 'ico';
} elseif (stripos($ct, 'jpeg') !== false) {
$ext = 'jpg';
} elseif (stripos($ct, 'webp') !== false) {
$ext = 'webp';
} elseif (stripos($ct, 'gif') !== false) {
$ext = 'gif';
}
return ['ext' => $ext, 'data' => $body];
} catch (\Throwable $e) {
continue;
}
}
return false;
}
/**
* 根据 Content-Type 推断图片扩展名
*/
protected function extFromCt(string $ct, string $default = 'ico'): string
{
if (stripos($ct, 'svg') !== false) {
return 'svg';
}
if (stripos($ct, 'png') !== false) {
return 'png';
}
if (stripos($ct, 'jpeg') !== false) {
return 'jpg';
}
if (stripos($ct, 'webp') !== false) {
return 'webp';
}
if (stripos($ct, 'gif') !== false) {
return 'gif';
}
if (stripos($ct, 'ico') !== false || stripos($ct, 'x-icon') !== false) {
return 'ico';
}
return $default;
}
/**
* 从首页 HTML 中解析图标地址,兼容相对/绝对/协议相对路径与 apple-touch-icon。
* 优先返回普通 icon(体积小),apple-touch-icon 作为兜底。
*/
protected function parseIconHref(string $html, string $base): string
{
if (! preg_match_all('/]*rel=["\'][^"\']*icon[^"\']*["\'][^>]*>/is', $html, $matches)) {
return '';
}
$candidates = [];
foreach ($matches[0] as $tag) {
if (! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $m)) {
continue;
}
$candidates[] = [
'href' => $m[1],
'apple' => stripos($tag, 'apple-touch-icon') !== false,
];
}
usort($candidates, fn($a, $b) => ($a['apple'] <=> $b['apple']));
$base = rtrim($base, '/');
foreach ($candidates as $c) {
$href = $c['href'];
if (preg_match('/^https?:\/\//i', $href)) {
return $href;
}
if (strpos($href, '//') === 0) {
return 'https:' . $href;
}
return $base . ($href[0] === '/' ? $href : '/' . $href);
}
return '';
}
}