[ 'timeout' => $timeout, 'header' => "User-Agent: Mozilla/5.0 SimpleNav\r\n", 'follow_location' => 1, ]]); $body = @file_get_contents($url, false, $ctx, 0, $maxBytes); if ($body === false) { return null; } return ['body' => $body, 'url' => $url, 'type' => '']; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36', CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_ENCODING => '', CURLOPT_HTTPHEADER => ['Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'], ]); $body = curl_exec($ch); $type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = curl_errno($ch); curl_close($ch); if ($err || $body === false || $code >= 400) { return null; } return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type]; } /* ========================================================================= * 元信息抓取:标题 / 名称 / 图标候选 * ========================================================================= */ /** * 抓取网站元信息。 * icons 为「按清晰度从高到低」排好的候选图标地址列表: * manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico * * @return array{title:string,name:string,icon:string,icons:string[],final_url:string} */ /** * 抓取网站元信息。 * icons 为「按清晰度从高到低」排好的候选图标地址列表: * manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico * * @return array{title:string,name:string,icon:string,icons:string[],final_url:string} */ private static function fetch_site_meta(string $url): array { $url = Nav::normUrl($url); $out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url]; $resp = http_get($url); if ($resp === null) { $out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url)); $out['icon'] = $out['icons'][0] ?? ''; return $out; } $html = ensure_utf8((string)$resp['body']); $final = $resp['url'] !== '' ? $resp['url'] : $url; $out['final_url'] = $final; // ---- 标题 / 名称 ---- if (preg_match('#]*>(.*?)#is', $html, $m)) { $out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'))); } $out['name'] = meta_content($html, 'og:site_name'); if ($out['name'] === '' && $out['title'] !== '') { // 用标题的首段(按分隔符切分)作为名称 $seg = preg_split('#[\|\-_—·::]#u', $out['title']); $out['name'] = trim($seg[0] ?? ''); } // ---- 图标候选(按清晰度从高到低)---- // 1. manifest 里的大图标(常见 512/192) // 2. apple-touch-icon(常见 180) // 3. ,按 sizes 降序(SVG 视为最高清) // 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到) // 5. 兜底:站点自身 /favicon.ico [$links, $manifestUrl] = parse_icon_links($html, $final); $icons = []; if ($manifestUrl !== '') { foreach (manifest_icons($manifestUrl) as $u) { $icons[] = $u; } } foreach ($links as $u) { $icons[] = $u; } foreach (third_party_icon_urls($final) as $u) { $icons[] = $u; } foreach (default_icon_candidates($final) as $u) { $icons[] = $u; } $out['icons'] = array_values(array_unique(array_filter($icons, static function ($u) { return is_string($u) && preg_match('#^https?://#i', $u); }))); $out['icon'] = $out['icons'][0] ?? ''; return $out; } /** * 联网搜索高清图标的第三方聚合服务。 * 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG); * 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。 * 可在后台「系统设置」里用 icon_services 关闭。 */ /** * 联网搜索高清图标的第三方聚合服务。 * 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG); * 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。 * 可在后台「系统设置」里用 icon_services 关闭。 */ private static function third_party_icon_urls(string $url): array { if (self::iconServices() !== '1') { return []; } $host = parse_url(Nav::normUrl($url), PHP_URL_HOST); if (!$host) { return []; } $host = strtolower($host); return [ 'https://favicon.yandex.net/favicon/' . $host . '?size=120', // 实测 100% 可用,120×120 'https://icon.horse/icon/' . $host, // 可达时最高 256×256,偶有超时 ]; } /** 取 的值(属性顺序无关) */ /** 取 的值(属性顺序无关) */ private static function meta_content(string $html, string $key): string { if (!preg_match_all('#]*>#i', $html, $tags)) { return ''; } $k = preg_quote($key, '#'); foreach ($tags[0] as $t) { if (!preg_match('#\b(?:property|name|itemprop)\s*=\s*(?:"' . $k . '"|\'' . $k . '\'|' . $k . '[\s/>])#i', $t)) { continue; } if (preg_match('#\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $t, $cm)) { $v = $cm[1] !== '' ? $cm[1] : ($cm[2] !== '' ? $cm[2] : ($cm[3] ?? '')); return trim(html_entity_decode($v, ENT_QUOTES, 'UTF-8')); } } return ''; } /** * 解析页面里的 图标声明。 * @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL] */ /** * 解析页面里的 图标声明。 * @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL] */ private static function parse_icon_links(string $html, string $base): array { $apple = []; $rel = []; $manifest = ''; if (preg_match_all('#]*>#i', $html, $tags)) { foreach ($tags[0] as $tag) { $relv = attr_value($tag, 'rel'); if ($relv === '') { continue; } $relv = strtolower($relv); $href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8'); if ($href === '') { continue; } if (strpos($relv, 'apple-touch-icon') !== false) { $apple[] = $href; continue; } if (strpos($relv, 'manifest') !== false) { if ($manifest === '') { $manifest = $href; } continue; } if (strpos($relv, 'icon') === false) { continue; } $size = 0; $raw = attr_value($tag, 'sizes'); if ($raw !== '') { foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) { if (stripos($one, 'any') !== false) { $size = max($size, 256); } elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) { $size = max($size, (int)$gm[1], (int)$gm[2]); } } } // SVG 是矢量,视为最高清 if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) { $size = max($size, 512); } $rel[] = ['size' => $size, 'url' => $href]; } } usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; }); $list = []; foreach ($apple as $h) { $list[] = absolutize($h, $base); } foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); } return [$list, $manifest === '' ? '' : absolutize($manifest, $base)]; } /** 读取 web app manifest,返回其中图标(按尺寸降序) */ /** 读取 web app manifest,返回其中图标(按尺寸降序) */ private static function manifest_icons(string $manifestUrl): array { $r = http_get($manifestUrl, 200000, 6); if ($r === null) { return []; } $json = json_decode(trim((string)$r['body']), true); if (!is_array($json) || empty($json['icons']) || !is_array($json['icons'])) { return []; } $base = $r['url'] !== '' ? $r['url'] : $manifestUrl; $list = []; foreach ($json['icons'] as $ic) { if (empty($ic['src']) || !is_string($ic['src'])) { continue; } $size = 0; if (!empty($ic['sizes'])) { foreach (preg_split('/[\s,]+/', (string)$ic['sizes']) ?: [] as $one) { if (stripos($one, 'any') !== false) { $size = max($size, 512); } elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) { $size = max($size, (int)$gm[1], (int)$gm[2]); } } } $list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)]; } usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; }); return array_values(array_unique(array_column($list, 'url'))); } /** 兜底候选:站点自身 favicon.ico */ /** 兜底候选:站点自身 favicon.ico */ private static function default_icon_candidates(string $url): array { $p = parse_url(Nav::normUrl($url)); if (empty($p['host'])) { return []; } $scheme = !empty($p['scheme']) ? $p['scheme'] : 'https'; return [$scheme . '://' . $p['host'] . '/favicon.ico']; } /** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */ /** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */ private static function attr_value(string $tag, string $attr): string { $a = preg_quote($attr, '#'); if (preg_match('#\b' . $a . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $tag, $m)) { $v = $m[1] !== '' ? $m[1] : ($m[2] !== '' ? $m[2] : ($m[3] ?? '')); return trim($v); } return ''; } /* ========================================================================= * 图标下载与规范化 * ========================================================================= */ /** * 图标文件的命名键:**按整条网址**算,而不是只按域名。 * * 为什么不能只用域名(2026-09-28 修的真实 bug): * 同一域名下不同路径往往是两个独立的网站条目,例如 * https://nas.example.com/calendar 与 https://nas.example.com/ * 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标, * 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。 * * 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异): * - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目) * - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个) * - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息 * 一句话:**不同网址 → 不同图标文件**。 * * 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url, * 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。 * * 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定, * 不影响任何行为)。 */ /** * 图标文件的命名键:**按整条网址**算,而不是只按域名。 * * 为什么不能只用域名(2026-09-28 修的真实 bug): * 同一域名下不同路径往往是两个独立的网站条目,例如 * https://nas.example.com/calendar 与 https://nas.example.com/ * 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标, * 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。 * * 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异): * - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目) * - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个) * - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息 * 一句话:**不同网址 → 不同图标文件**。 * * 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url, * 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。 * * 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定, * 不影响任何行为)。 */ private static function icon_key(string $url): string { $u = trim($url); if ($u === '') { return ''; } if (!preg_match('#^https?://#i', $u)) { $u = 'http://' . $u; } $p = @parse_url($u); if (!is_array($p) || empty($p['host'])) { return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞 } $norm = strtolower((string)$p['scheme']) . '://' . strtolower((string)$p['host']) . (empty($p['port']) ? '' : ':' . $p['port']) . (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/') . (isset($p['query']) ? '?' . $p['query'] : '') . (isset($p['fragment']) ? '#' . $p['fragment'] : ''); return md5($norm); } /** * 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。 * 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。 * * @param string[] $urls 候选图标地址(已按优先级排序) * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) */ /** * 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。 * 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。 * * @param string[] $urls 候选图标地址(已按优先级排序) * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) */ private static function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string { if ($key === '') { // 没有键就不写文件,避免全部落到 md5('') 上互相覆盖 return ''; } if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) { @mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 0755, true); } $best = null; $bestScore = -1; $tmps = []; $tried = 0; $deadline = microtime(true) + $budget; foreach ($urls as $u) { if (!is_string($u) || $u === '' || !preg_match('#^https?://#i', $u)) { continue; } if ($tried >= 8 || microtime(true) > $deadline) { // 候选上限 + 耗时预算 break; } $tried++; $r = grab_icon($u); if ($r === null) { continue; } // 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图, // 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。 if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) { @unlink($r['tmp']); continue; } $tmps[] = $r['tmp']; $score = min($r['w'], $r['h']); if ($score > $bestScore) { $bestScore = $score; $best = $r; } if ($score >= $minGood) { break; } } // 清理落选临时文件 foreach ($tmps as $t) { if (!$best || $t !== $best['tmp']) { @unlink($t); } } if ($best === null) { return ''; } $md5 = md5($key); $file = $md5 . '.' . $best['ext']; $dest = FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file; @unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png'); @unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg'); if (!@copy($best['tmp'], $dest)) { @unlink($best['tmp']); return ''; } @unlink($best['tmp']); return is_file($dest) ? $file : ''; } /** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */ /** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */ private static function is_third_party_icon(string $url): bool { $host = strtolower((string)parse_url($url, PHP_URL_HOST)); return $host === 'favicon.yandex.net' || $host === 'icon.horse'; } /** * 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。 * 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面, * 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。 */ /** * 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。 * 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面, * 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。 */ private static function looks_generated_tile(string $file): bool { $im = @imagecreatefromstring((string)@file_get_contents($file)); if ($im === false) { return false; } $w = imagesx($im); $h = imagesy($im); $sx = max(1, (int)($w / 28)); $sy = max(1, (int)($h / 28)); $maxChroma = 0; $n = 0; $light = 0; $sum = 0.0; for ($y = 0; $y < $h; $y += $sy) { for ($x = 0; $x < $w; $x += $sx) { $rgba = imagecolorat($im, $x, $y); if ((($rgba >> 24) & 0x7F) > 110) { // 透明像素跳过 continue; } $r = ($rgba >> 16) & 0xFF; $g = ($rgba >> 8) & 0xFF; $b = $rgba & 0xFF; $chroma = max($r, $g, $b) - min($r, $g, $b); if ($chroma > $maxChroma) { $maxChroma = $chroma; } $v = ($r + $g + $b) / 3; $sum += $v; if ($v >= 195 && $chroma < 16) { $light++; } $n++; } } imagedestroy($im); if ($n < 24) { return false; } return $maxChroma < 16 && ($sum / $n) > 190 && ($light / $n) >= 0.80; } /** * 下载单个图标到临时文件。 * @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优 */ /** * 下载单个图标到临时文件。 * @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优 */ private static function grab_icon(string $iconUrl): ?array { $resp = http_get($iconUrl, 2 * 1024 * 1024, 5); if ($resp === null) { return null; } $bin = (string)$resp['body']; if (strlen($bin) < 64 || strlen($bin) > 3 * 1024 * 1024) { return null; } $tmp = @tempnam(sys_get_temp_dir(), 'snic'); if ($tmp === false) { return null; } // ---- SVG:直接保存(矢量最清晰),挡掉带脚本的 ---- $head = ltrim(substr($bin, 0, 1024)); $isSvg = stripos((string)$resp['type'], 'svg') !== false || stripos($head, ' 512 * 1024 || preg_match('# $tmp, 'ext' => 'svg', 'w' => 512, 'h' => 512]; } // ---- 位图:GD 归一化为 128×128 PNG(保持比例、透明留白) ---- if (!function_exists('imagecreatefromstring')) { @unlink($tmp); return null; } $im = @imagecreatefromstring($bin); if ($im === false) { @unlink($tmp); return null; } $w = imagesx($im); $h = imagesy($im); if ($w < 8 || $h < 8) { // 太小/损坏,判为无效 imagedestroy($im); @unlink($tmp); return null; } $store = ICON_STORE; $canvas = imagecreatetruecolor($store, $store); imagealphablending($canvas, false); imagesavealpha($canvas, true); imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127)); $scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊 $nw = max(1, (int)round($w * $scale)); $nh = max(1, (int)round($h * $scale)); imagecopyresampled( $canvas, $im, (int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0, $nw, $nh, $w, $h ); imagealphablending($canvas, true); $ok = imagepng($canvas, $tmp, 6); imagedestroy($canvas); imagedestroy($im); if ($ok === false) { @unlink($tmp); return null; } return ['tmp' => $tmp, 'ext' => 'png', 'w' => $w, 'h' => $h]; } /** * 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。 * * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) */ /** * 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。 * * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) */ private static function save_custom_icon(string $binary, string $key): string { if ($key === '') { return ''; } if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) { @mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 0755, true); } $mime = ''; if (function_exists('finfo_open')) { $fi = finfo_open(FILEINFO_MIME_TYPE); $mime = (string)finfo_buffer($fi, $binary); finfo_close($fi); } if (strpos($mime, 'svg') !== false || stripos(ltrim(substr($binary, 0, 1024)), ' 文件基名 => 二进制(基名已含扩展名,如 'logo.png') */ /** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */ private static function make_ico(array $pngs): string { $frames = []; foreach ($pngs as $png) { $im = @imagecreatefromstring($png); if ($im === false) { continue; } $size = (int)imagesx($im); if ($size < 1 || $size > 256 || imagesy($im) !== $size) { imagedestroy($im); continue; } // BITMAPINFOHEADER 40 字节:biSize biWidth biHeight biPlanes biBitCount // biCompression biSizeImage biXPels biYPels biClrUsed biClrImportant // (高度写 2 倍:ICO 的 DIB 把 XOR 位图与 AND 掩码叠在一起算高度) $header = pack('VVVvvVVVVVV', 40, $size, $size * 2, 1, 32, 0, 0, 0, 0, 0, 0); $xor = ''; for ($y = $size - 1; $y >= 0; $y--) { for ($x = 0; $x < $size; $x++) { $rgba = imagecolorat($im, $x, $y); $a = ($rgba >> 24) & 0x7F; $xor .= chr($rgba & 0xFF) . chr(($rgba >> 8) & 0xFF) . chr(($rgba >> 16) & 0xFF) . chr((int)round(255 - $a * 2.00787)); } } imagedestroy($im); $and = str_repeat("\x00", (int)($size / 8) * $size); // 1bpp 掩码,全 0 = 全部可见 $frames[$size] = $header . $xor . $and; } if (!$frames) { return ''; } ksort($frames); $n = count($frames); $ico = pack('vvv', 0, 1, $n); $offset = 6 + $n * 16; foreach ($frames as $size => $data) { // 宽高字节:256 要写成 0 $b = ($size >= 256) ? 0 : $size; $ico .= pack('CCCCvvVV', $b, $b, 0, 0, 1, 32, strlen($data), $offset); $offset += strlen($data); } foreach ($frames as $data) { $ico .= $data; } return $ico; } /** 整套图标清单:文件基名 => 尺寸 */ /** 把可能为相对路径的 URL 补全为绝对 URL */ private static function absolutize(string $href, string $base): string { // 有些页面把链接写成 JSON 转义形式(https:\/\/x\/y.ico),先还原 $href = str_replace('\\/', '/', trim($href)); if (preg_match('#^https?://#i', $href)) { return $href; } $p = parse_url($base); $root = ($p['scheme'] ?? 'http') . '://' . ($p['host'] ?? ''); if (strpos($href, '//') === 0) { return ($p['scheme'] ?? 'http') . ':' . $href; } if (strpos($href, '/') === 0) { return $root . $href; } $dir = rtrim(dirname($p['path'] ?? '/'), '/'); return $root . $dir . '/' . $href; } /** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */ /** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */ private static function ensure_utf8(string $s): string { if (preg_match('#]+charset=["\']?\s*(gbk|gb2312|gb18030)#i', $s)) { if (function_exists('mb_convert_encoding')) { return mb_convert_encoding($s, 'UTF-8', 'GB18030'); } } if (function_exists('mb_check_encoding') && !mb_check_encoding($s, 'UTF-8')) { return mb_convert_encoding($s, 'UTF-8', 'GB18030'); } return $s; } /** 图标显示地址(为空时返回 '',前端回退到首字母块) */ }