[ '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 (compatible; SimpleNav/1.0)', CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_ENCODING => '', ]); $body = curl_exec($ch); $type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); $err = curl_errno($ch); curl_close($ch); if ($err || $body === false) { return null; } return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type]; } /** * 抓取网站元信息:标题、名称、图标地址。 * @return array{title:string,name:string,icon:string,final_url:string} */ function fetch_site_meta(string $url): array { $url = norm_url($url); $out = ['title' => '', 'name' => '', 'icon' => '', 'final_url' => $url]; $resp = http_get($url); if ($resp === null) { return $out; } $html = $resp['body']; $final = $resp['url']; $out['final_url'] = $final; // 编码:优先按 转换到 UTF-8 $html = ensure_utf8($html); // if (preg_match('#<title[^>]*>(.*?)#is', $html, $m)) { $out['title'] = trim(preg_replace('/\s+/', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'))); } // og:site_name 优先作为"名称" if (preg_match('#]+property=["\']og:site_name["\'][^>]+content=["\']([^"\']+)#i', $html, $m)) { $out['name'] = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')); } elseif ($out['title'] !== '') { // 用标题的首段(按分隔符切分)作为名称 $t = preg_split('#[\|\-_—·::]#u', $out['title']); $out['name'] = trim($t[0]); } // 图标: $icon = ''; if (preg_match_all('#]+>#i', $html, $links)) { foreach ($links[0] as $tag) { if (preg_match('#rel=["\'][^"\']*icon[^"\']*["\']#i', $tag)) { if (preg_match('#href=["\']([^"\']+)["\']#i', $tag, $hm)) { $cand = html_entity_decode($hm[1], ENT_QUOTES, 'UTF-8'); // 优先 apple-touch-icon(通常更大更清晰) if (stripos($tag, 'apple-touch') !== false) { $icon = $cand; break; } if ($icon === '') { $icon = $cand; } } } } } if ($icon !== '') { $out['icon'] = absolutize($icon, $final); } else { $p = parse_url($final); if (!empty($p['scheme']) && !empty($p['host'])) { $out['icon'] = $p['scheme'] . '://' . $p['host'] . '/favicon.ico'; } } return $out; } /** 把可能为相对路径的 URL 补全为绝对 URL */ function absolutize(string $href, string $base): string { 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 */ 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; } /** * 下载图标并保存到本地(PNG,64x64,透明背景)。 * @return string 本地文件名(如 ab12cd.png),失败返回 '' */ function download_icon(string $iconUrl, string $key, int $size = 64): string { if ($iconUrl === '') { return ''; } if (!is_dir(ICON_PATH)) { @mkdir(ICON_PATH, 0755, true); } $resp = http_get($iconUrl, 500000, 8); if ($resp === null || $resp['body'] === '') { return ''; } $bin = $resp['body']; $name = md5($key) . '.png'; $dest = ICON_PATH . '/' . $name; // 用 GD 统一转成 PNG(顺带裁成正方形、缩放) if (function_exists('imagecreatefromstring')) { $im = @imagecreatefromstring($bin); if ($im !== false) { $w = imagesx($im); $h = imagesy($im); $side = min($w, $h); $square = imagecreatetruecolor($size, $size); imagealphablending($square, false); imagesavealpha($square, true); $trans = imagecolorallocatealpha($square, 0, 0, 0, 127); imagefill($square, 0, 0, $trans); $sx = (int)(($w - $side) / 2); $sy = (int)(($h - $side) / 2); imagecopyresampled($square, $im, 0, 0, $sx, $sy, $size, $size, $side, $side); imagepng($square, $dest); imagedestroy($square); imagedestroy($im); return $name; } } // 无 GD:原样保存(按 content-type 猜扩展名) $ext = 'ico'; if (stripos($resp['type'], 'png') !== false) { $ext = 'png'; } elseif (stripos($resp['type'], 'svg') !== false) { $ext = 'svg'; } elseif (stripos($resp['type'], 'jpeg') !== false || stripos($resp['type'], 'jpg') !== false) { $ext = 'jpg'; } elseif (stripos($resp['type'], 'gif') !== false) { $ext = 'gif'; } $name = md5($key) . '.' . $ext; @file_put_contents(ICON_PATH . '/' . $name, $bin); return is_file(ICON_PATH . '/' . $name) ? $name : ''; } /** 图标显示地址(为空时返回 '',前端回退到首字母块) */ function icon_src(string $file): string { return $file === '' ? '' : url(ICON_URL . '/' . $file); } /** 名称首字配色(无图标时的回退头像底色) */ function letter_color(string $s): string { $palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316']; return $palette[abs(crc32($s)) % count($palette)]; }