[
'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}
*/
function fetch_site_meta(string $url): array
{
$url = norm_url($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 关闭。
*/
function third_party_icon_urls(string $url): array
{
if (setting('icon_services', '1') !== '1') {
return [];
}
$host = parse_url(norm_url($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,偶有超时
];
}
/** 取 的值(属性顺序无关) */
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]
*/
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,返回其中图标(按尺寸降序) */
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 */
function default_icon_candidates(string $url): array
{
$p = parse_url(norm_url($url));
if (empty($p['host'])) {
return [];
}
$scheme = !empty($p['scheme']) ? $p['scheme'] : 'https';
return [$scheme . '://' . $p['host'] . '/favicon.ico'];
}
/** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */
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 '';
}
/* =========================================================================
* 图标下载与规范化
* ========================================================================= */
/**
* 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。
* 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。
*
* @param string[] $urls 候选图标地址(已按优先级排序)
*/
function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string
{
if (!is_dir(ICON_PATH)) {
@mkdir(ICON_PATH, 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 = ICON_PATH . '/' . $file;
@unlink(ICON_PATH . '/' . $md5 . '.png');
@unlink(ICON_PATH . '/' . $md5 . '.svg');
if (!@copy($best['tmp'], $dest)) {
@unlink($best['tmp']);
return '';
}
@unlink($best['tmp']);
return is_file($dest) ? $file : '';
}
/** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */
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 都不会命中。
*/
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 为「原始」尺寸,用于择优
*/
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, '