[
'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('#]*>(.*?)#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)];
}