238 lines
7.8 KiB
PHP
238 lines
7.8 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 简航 SimpleNav —— 公共函数
|
|||
|
|
*/
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
require_once __DIR__ . '/config.php';
|
|||
|
|
require_once __DIR__ . '/db.php';
|
|||
|
|
|
|||
|
|
/** HTML 转义 */
|
|||
|
|
function e(?string $s): string
|
|||
|
|
{
|
|||
|
|
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 规范化 URL:补协议、去空白 */
|
|||
|
|
function norm_url(string $url): string
|
|||
|
|
{
|
|||
|
|
$url = trim($url);
|
|||
|
|
if ($url === '') {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
if (!preg_match('#^https?://#i', $url)) {
|
|||
|
|
$url = 'http://' . $url;
|
|||
|
|
}
|
|||
|
|
return $url;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 取主域名(用于 favicon 与文件名) */
|
|||
|
|
function domain_of(string $url): string
|
|||
|
|
{
|
|||
|
|
$host = parse_url(norm_url($url), PHP_URL_HOST);
|
|||
|
|
return $host ? preg_replace('/^www\./i', '', $host) : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** curl 抓取文本(带 UA、超时、跟随跳转、限长) */
|
|||
|
|
function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
|
|||
|
|
{
|
|||
|
|
if (!function_exists('curl_init')) {
|
|||
|
|
// 兜底:allow_url_fopen
|
|||
|
|
$ctx = stream_context_create(['http' => [
|
|||
|
|
'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;
|
|||
|
|
|
|||
|
|
// 编码:优先按 <meta charset> 转换到 UTF-8
|
|||
|
|
$html = ensure_utf8($html);
|
|||
|
|
|
|||
|
|
// <title>
|
|||
|
|
if (preg_match('#<title[^>]*>(.*?)</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('#<meta[^>]+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]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 图标:<link rel="...icon...">
|
|||
|
|
$icon = '';
|
|||
|
|
if (preg_match_all('#<link[^>]+>#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('#<meta[^>]+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)];
|
|||
|
|
}
|