2026-10-07 05:52:40 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 导航的「网站元信息与图标抓取」(v24 第 24 批 用户第 4 条)——
|
|
|
|
|
|
* **从用户自己的 SimpleNav 项目整段搬运**,逻辑一行未改,只做了三处机械改写:
|
|
|
|
|
|
*
|
|
|
|
|
|
* ① 自由函数 → `final class NavFetch` 的私有静态方法(家书是类的风格);
|
|
|
|
|
|
* ② 常量 `ICON_PATH` / `ICON_URL` → `Nav::ICON_DIR` / `Nav::ICON_URL`;
|
|
|
|
|
|
* ③ 与家书**重名**的几个(`e` / `norm_url` / `domain_of` / `letter_color`)改调
|
|
|
|
|
|
* `Nav::` 上的同名方法 —— 不搬一份进来,否则函数重定义直接 Fatal。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 原文里那些"线上实测"的注释**全部保留**(例如"哪几个第三方图标服务可达、
|
|
|
|
|
|
* 哪几个不可达")。它们是这个子系统里最贵的部分 —— 重踩一遍要花一整轮。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 这一批方法是**要联网的**(每个站点一次 HTTP + 可能多次图标下载)。
|
|
|
|
|
|
* 所以只在用户**主动点「抓取」/「全部更新」**时调,页面渲染绝不调。
|
|
|
|
|
|
*/
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class NavFetch
|
|
|
|
|
|
{
|
|
|
|
|
|
/** 第三方图标聚合服务开关(原项目是后台设置 icon_services) */
|
|
|
|
|
|
private static bool $iconServices = true;
|
|
|
|
|
|
|
|
|
|
|
|
public static function setIconServices(bool $on): void
|
|
|
|
|
|
{
|
|
|
|
|
|
self::$iconServices = $on;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static function iconServices(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::$iconServices ? '1' : '0';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** HTTP 抓取(带 UA / 超时 / 跟随跳转)—— 供外部复用 */
|
|
|
|
|
|
public static function get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::http_get($url, $maxBytes, $timeout);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 抓一个站点的元信息(标题 / 名称 + 图标候选)—— 对外的唯一入口。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 搬进来的原函数叫 `fetch_site_meta()`,这里只是换一个符合家书命名风格的
|
|
|
|
|
|
* public 名字,逻辑一行没动。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function meta(string $url): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return self::fetch_site_meta($url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 抓取并**落盘**一个站点的图标,返回文件名(失败返回 '')。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 这是 SimpleNav「抓图标」那段业务逻辑的落点:按清晰度依次尝试候选
|
|
|
|
|
|
* (manifest 大图 → apple-touch → link[rel=icon] → 第三方聚合 → /favicon.ico),
|
|
|
|
|
|
* 内部就是 `download_best_icon()` —— 逐个下载、挑最清晰的一张、存成统一尺寸。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param array $meta self::meta() 的返回值
|
|
|
|
|
|
* @param string $url 站点地址(文件名 = md5(url),与 SimpleNav 同规则)
|
|
|
|
|
|
* @param bool $allowThirdParty 允许用第三方图标聚合服务
|
|
|
|
|
|
* (会让这一步多发几次外网请求;哪些可达见
|
|
|
|
|
|
* third_party_icon_urls 里那批实测注释)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function fetchIcon(array $meta, string $url, bool $allowThirdParty = true): string
|
|
|
|
|
|
{
|
|
|
|
|
|
self::$iconServices = $allowThirdParty;
|
|
|
|
|
|
$key = self::icon_key($url);
|
|
|
|
|
|
if ($key === '') {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$dir = FB_ROOT . '/' . Nav::ICON_DIR;
|
|
|
|
|
|
if (!is_dir($dir)) {
|
|
|
|
|
|
@mkdir($dir, 0755, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
return self::download_best_icon((array)($meta['icons'] ?? []), $key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 用户自传的图标二进制落盘,返回文件名。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 落盘后调用方**必须**把 `nav_sites.icon_custom` 置 1 ——
|
|
|
|
|
|
* 否则下一次「全部更新」会把用户精心挑的图标冲掉(SimpleNav 原文明确要求)。
|
2026-10-28 12:48:47 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 🔴 工单 #16:这里**不能**再用 `icon_key($url)`(= md5(网址))当文件名。
|
|
|
|
|
|
* 原项目 SimpleNav 是**单用户**系统,"一个网址一个图标文件"没问题;
|
|
|
|
|
|
* 家书是**多用户**的,两个人给同一个网址上传不同图标时,
|
|
|
|
|
|
* md5(url) 会让后传的**直接覆盖**先传的 —— A 在自己导航里看到 B 的图标,
|
|
|
|
|
|
* 而且 A 那条的 `icon` 字段还指向这个文件,等于两个人的图标互相串。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 改用 `random_bytes` 生成**每次唯一**的文件名(与 `Nav::copyIconFile()`
|
|
|
|
|
|
* 同一套做法):上传一次一个文件,换图标就换文件名,
|
|
|
|
|
|
* 旧的由调用方显式删除(`Nav::deleteIconFile`,且只在没人复用时删)。
|
2026-10-07 05:52:40 +08:00
|
|
|
|
*/
|
2026-10-28 12:48:47 +08:00
|
|
|
|
public static function saveUploaded(string $binary, string $url = ''): string
|
2026-10-07 05:52:40 +08:00
|
|
|
|
{
|
2026-10-28 12:48:47 +08:00
|
|
|
|
// $url 保留在签名里只为兼容旧调用;不再参与文件命名(见上面说明)。
|
|
|
|
|
|
// save_custom_icon 内部取 md5($key) 当文件名 ⇒ 传一个随机键即得唯一文件名
|
|
|
|
|
|
// (结果是 32 位十六进制,正好符合 nav_icon.php 的白名单)。
|
|
|
|
|
|
return self::save_custom_icon($binary, bin2hex(random_bytes(16)));
|
2026-10-07 05:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** curl 抓取(带 UA、超时、跟随跳转) */
|
|
|
|
|
|
private static 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 (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];
|
|
|
|
|
|
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$resp = self::http_get($url);
|
2026-10-07 05:52:40 +08:00
|
|
|
|
if ($resp === null) {
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$out['icons'] = array_merge(self::third_party_icon_urls($url), self::default_icon_candidates($url));
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$out['icon'] = $out['icons'][0] ?? '';
|
|
|
|
|
|
return $out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$html = self::ensure_utf8((string)$resp['body']);
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$final = $resp['url'] !== '' ? $resp['url'] : $url;
|
|
|
|
|
|
$out['final_url'] = $final;
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 标题 / 名称 ----
|
|
|
|
|
|
if (preg_match('#<title[^>]*>(.*?)</title>#is', $html, $m)) {
|
|
|
|
|
|
$out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')));
|
|
|
|
|
|
}
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$out['name'] = self::meta_content($html, 'og:site_name');
|
2026-10-07 05:52:40 +08:00
|
|
|
|
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. <link rel="icon">,按 sizes 降序(SVG 视为最高清)
|
|
|
|
|
|
// 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到)
|
|
|
|
|
|
// 5. 兜底:站点自身 /favicon.ico
|
2026-10-28 12:48:47 +08:00
|
|
|
|
[$links, $manifestUrl] = self::parse_icon_links($html, $final);
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$icons = [];
|
|
|
|
|
|
if ($manifestUrl !== '') {
|
2026-10-28 12:48:47 +08:00
|
|
|
|
foreach (self::manifest_icons($manifestUrl) as $u) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$icons[] = $u;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach ($links as $u) {
|
|
|
|
|
|
$icons[] = $u;
|
|
|
|
|
|
}
|
2026-10-28 12:48:47 +08:00
|
|
|
|
foreach (self::third_party_icon_urls($final) as $u) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$icons[] = $u;
|
|
|
|
|
|
}
|
2026-10-28 12:48:47 +08:00
|
|
|
|
foreach (self::default_icon_candidates($final) as $u) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$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,偶有超时
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
|
|
|
|
|
|
|
|
|
|
|
|
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
|
|
|
|
|
|
private static function meta_content(string $html, string $key): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!preg_match_all('#<meta\b[^>]*>#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 '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析页面里的 <link> 图标声明。
|
|
|
|
|
|
* @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL]
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 解析页面里的 <link> 图标声明。
|
|
|
|
|
|
* @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('#<link\b[^>]*>#i', $html, $tags)) {
|
|
|
|
|
|
foreach ($tags[0] as $tag) {
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$relv = self::attr_value($tag, 'rel');
|
2026-10-07 05:52:40 +08:00
|
|
|
|
if ($relv === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$relv = strtolower($relv);
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$href = html_entity_decode(self::attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8');
|
2026-10-07 05:52:40 +08:00
|
|
|
|
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;
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$raw = self::attr_value($tag, 'sizes');
|
2026-10-07 05:52:40 +08:00
|
|
|
|
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 是矢量,视为最高清
|
2026-10-28 12:48:47 +08:00
|
|
|
|
if (preg_match('#\.svg(\?|$)#i', $href) || stripos(self::attr_value($tag, 'type'), 'svg') !== false) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
$size = max($size, 512);
|
|
|
|
|
|
}
|
|
|
|
|
|
$rel[] = ['size' => $size, 'url' => $href];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; });
|
|
|
|
|
|
|
|
|
|
|
|
$list = [];
|
2026-10-28 12:48:47 +08:00
|
|
|
|
foreach ($apple as $h) { $list[] = self::absolutize($h, $base); }
|
|
|
|
|
|
foreach ($rel as $r) { $list[] = self::absolutize($r['url'], $base); }
|
|
|
|
|
|
return [$list, $manifest === '' ? '' : self::absolutize($manifest, $base)];
|
2026-10-07 05:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
|
|
|
|
|
|
|
|
|
|
|
|
/** 读取 web app manifest,返回其中图标(按尺寸降序) */
|
|
|
|
|
|
private static function manifest_icons(string $manifestUrl): array
|
|
|
|
|
|
{
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$r = self::http_get($manifestUrl, 200000, 6);
|
2026-10-07 05:52:40 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$list[] = ['size' => $size, 'url' => self::absolutize($ic['src'], $base)];
|
2026-10-07 05:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
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++;
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$r = self::grab_icon($u);
|
2026-10-07 05:52:40 +08:00
|
|
|
|
if ($r === null) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图,
|
|
|
|
|
|
// 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。
|
2026-10-28 12:48:47 +08:00
|
|
|
|
if ($r['ext'] === 'png' && self::is_third_party_icon($u) && self::looks_generated_tile($r['tmp'])) {
|
2026-10-07 05:52:40 +08:00
|
|
|
|
@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
|
|
|
|
|
|
{
|
2026-10-28 12:48:47 +08:00
|
|
|
|
$resp = self::http_get($iconUrl, 2 * 1024 * 1024, 5);
|
2026-10-07 05:52:40 +08:00
|
|
|
|
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, '<svg') !== false
|
|
|
|
|
|
|| strpos($head, '<?xml') === 0;
|
|
|
|
|
|
if ($isSvg) {
|
|
|
|
|
|
if (strlen($bin) > 512 * 1024
|
|
|
|
|
|
|| preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $bin)) {
|
|
|
|
|
|
@unlink($tmp);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (@file_put_contents($tmp, $bin) === false) {
|
|
|
|
|
|
@unlink($tmp);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
return ['tmp' => $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)), '<svg') !== false) {
|
|
|
|
|
|
if (preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $binary)) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$md5 = md5($key);
|
|
|
|
|
|
$file = $md5 . '.svg';
|
|
|
|
|
|
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png');
|
|
|
|
|
|
@file_put_contents(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, $binary);
|
|
|
|
|
|
return is_file(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file) ? $file : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!function_exists('imagecreatefromstring')) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$im = @imagecreatefromstring($binary);
|
|
|
|
|
|
if ($im === false) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$w = imagesx($im);
|
|
|
|
|
|
$h = imagesy($im);
|
|
|
|
|
|
if ($w < 8 || $h < 8) {
|
|
|
|
|
|
imagedestroy($im);
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
$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);
|
|
|
|
|
|
$md5 = md5($key);
|
|
|
|
|
|
$file = $md5 . '.png';
|
|
|
|
|
|
$ok = imagepng($canvas, FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, 6);
|
|
|
|
|
|
imagedestroy($canvas);
|
|
|
|
|
|
imagedestroy($im);
|
|
|
|
|
|
if ($ok === false) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg');
|
|
|
|
|
|
return $file;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* =========================================================================
|
|
|
|
|
|
* 站点整体图标(浏览器标签页 favicon + 首页品牌图标)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变:
|
|
|
|
|
|
* 静态优先(有就直出文件,兼容「部署目录只读」的保守环境)
|
|
|
|
|
|
* → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限)
|
|
|
|
|
|
* 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件,
|
|
|
|
|
|
* 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。
|
|
|
|
|
|
* ========================================================================= */
|
|
|
|
|
|
|
|
|
|
|
|
/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */
|
|
|
|
|
|
|
|
|
|
|
|
/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。
|
|
|
|
|
|
* ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */
|
|
|
|
|
|
private static function gd_load(string $binary)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!function_exists('imagecreatefromstring')) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$im = @imagecreatefromstring($binary);
|
|
|
|
|
|
return $im === false ? null : $im;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
|
|
|
|
|
|
|
|
|
|
|
|
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
|
|
|
|
|
|
private static function gd_square_png($im, int $size): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$w = imagesx($im);
|
|
|
|
|
|
$h = imagesy($im);
|
|
|
|
|
|
$side = min($w, $h);
|
|
|
|
|
|
$ox = (int)(($w - $side) / 2);
|
|
|
|
|
|
$oy = (int)(($h - $side) / 2);
|
|
|
|
|
|
|
|
|
|
|
|
$canvas = imagecreatetruecolor($size, $size);
|
|
|
|
|
|
imagealphablending($canvas, false);
|
|
|
|
|
|
imagesavealpha($canvas, true);
|
|
|
|
|
|
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
|
|
|
|
|
|
imagecopyresampled($canvas, $im, 0, 0, $ox, $oy, $size, $size, $side, $side);
|
|
|
|
|
|
imagealphablending($canvas, true);
|
|
|
|
|
|
|
|
|
|
|
|
ob_start();
|
|
|
|
|
|
$ok = imagepng($canvas, null, 9);
|
|
|
|
|
|
$png = (string)ob_get_clean();
|
|
|
|
|
|
imagedestroy($canvas);
|
|
|
|
|
|
return ($ok === false || $png === '') ? '' : $png;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 把任意图**居中裁剪成正方形**并生成整套尺寸。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么是「裁剪」而不是「留白」:站点图标是正方形用途(favicon / PWA 图标),
|
|
|
|
|
|
* 直接拉伸会把 logo 拉变形,留白则四周一圈透明、缩小后主体看不清。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<string,string> 文件基名 => 二进制(基名已含扩展名,如 '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('#<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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
|
|
|
|
|
|
}
|