// +---------------------------------------------------------------------- declare(strict_types=1); namespace addon\haonav\model; use ywxapp\model\BaseModel; use addon\haonav\model\Configure as ConfigureModel; use addon\haonav\library\Pinyin; class Links extends BaseModel { /** * 序列化时附加的虚拟字段(列表/详情输出分类名等)。 * 注意:getCateAttr 是虚拟字段获取器,ThinkPHP 默认 toArray 只输出真实表字段, * 必须在此声明 append 才会进入 JSON(控制器里用 withAttr('cate') 无效,会被链式查询丢弃)。 * @var array */ protected $append = ['cate', 'outbound_url']; protected function getOptions(): array { return [ 'strict' => false, 'name' => 'haonav_links', 'autoRelation' => [], 'createTime' => 'create_at', 'updateTime' => 'update_at', 'dateFormat' => 'Y-m-d H:i:s', ]; } /** * 展示用图标:优先 favicon,其次 icon,最后默认图 */ public function getShowIconAttr($value, $data) { $custom = !empty($data['favicon']) ? $data['favicon'] : (!empty($data['icon']) ? $data['icon'] : ''); // 自定义图标(本地上传或非 icon.horse 外链)直接返回,保留原图; // 自动生成的 icon.horse 地址改为走本地代理(带缓存与失败降级),提升可靠性与速度。 if ($custom && stripos($custom, 'icon.horse') === false) { return $custom; } $url = $data['url'] ?? ''; if ($url) { return '/haonav/favicon.html?url=' . urlencode($url); } return '/static/haonav/img/default.png'; } /** * 根据 URL 自动生成 icon.horse 的 favicon 地址 */ public static function faviconOf(string $url): string { $host = parse_url($url, PHP_URL_HOST); return $host ? 'https://icon.horse/icon/' . $host : ''; } /** * 返利外链:enable_rebate 开启、本链接 pid 非空、且域名命中 rebate_domains 时, * 自动拼接返利参数(如 ?pid=xxx)。用于详情页「立即跳转」与倒计时自动跳转,让导航站赚返利。 */ public function getOutboundUrlAttr($value, $data) { return self::buildOutboundUrl((string)($data['url'] ?? ''), (string)($data['pid'] ?? '')); } /** * 根据原始 URL + 返利 PID 生成返利外链(纯函数,便于测试与复用) */ public static function buildOutboundUrl(string $url, string $pid): string { if ($url === '' || $pid === '') { return $url; } if ((int)ConfigureModel::getVal('enable_rebate', '0') !== 1) { return $url; } $domains = self::rebateDomains(); if (empty($domains)) { return $url; } $host = parse_url($url, PHP_URL_HOST); if (!is_string($host) || $host === '') { return $url; } $host = strtolower($host); $matched = false; foreach ($domains as $d) { if ($host === $d || substr($host, -strlen($d) - 1) === '.' . $d) { $matched = true; break; } } if (!$matched) { return $url; } $param = (string)ConfigureModel::getVal('rebate_param', 'pid'); if ($param === '') { $param = 'pid'; } $sep = strpos($url, '?') !== false ? '&' : '?'; return $url . $sep . urlencode($param) . '=' . urlencode($pid); } /** * 解析返利适用域名后缀(配置为逗号/空格/换行分隔;支持子域名) */ protected static function rebateDomains(): array { $raw = (string)ConfigureModel::getVal('rebate_domains', ''); if ($raw === '') { return []; } $list = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY); return array_values(array_unique(array_map('strtolower', $list))); } /** * 插入前:自动补全 favicon / icon */ public static function onBeforeInsert($data) { if (empty($data->url)) { return true; } $favicon = self::faviconOf($data->url); if ($favicon && empty($data->favicon)) { $data->favicon = $favicon; } if (empty($data->icon) && !empty($data->favicon)) { $data->icon = $data->favicon; } if (!empty($data->title)) { $data->pinyin = Pinyin::keywordsOf((string)$data->title); } return true; } /** * 更新前:仅当网址变更时才抓取远端元信息,且失败不影响保存;并同步刷新 favicon */ public static function onBeforeUpdate($data) { if (empty($data->url)) { return true; } $changed = $data->getChangedData(); if (array_key_exists('url', $changed)) { try { $meta = self::fetchMeta($data->url); } catch (\Throwable $e) { $meta = false; } if (!empty($meta)) { $data->keywords = $meta['keywords'] ?: $data->keywords; $string = $meta['description'] ?: $data->description; $position = strpos($string, '。'); $data->description = $position !== false ? substr($string, 0, $position) : $string; } } // 始终保证 favicon/icon 存在 if (empty($data->favicon)) { $favicon = self::faviconOf($data->url); if ($favicon) { $data->favicon = $favicon; } } if (empty($data->icon) && !empty($data->favicon)) { $data->icon = $data->favicon; } if (!empty($data->title) && (array_key_exists('title', $changed) || empty($data->pinyin))) { $data->pinyin = Pinyin::keywordsOf((string)$data->title); } return true; } /** * 拼音回填:为存量数据补 pinyin(每次最多 $limit 条,幂等) * @return int 本次回填条数 */ public static function backfillPinyin(int $limit = 300): int { try { $rows = self::where(function ($q) { $q->whereNull('pinyin')->whereOr('pinyin', ''); })->limit($limit)->select(); $n = 0; foreach ($rows as $row) { $row->pinyin = Pinyin::keywordsOf((string)$row->title); $row->save(); $n++; } return $n; } catch (\Throwable $e) { return 0; } } /** * 关联分类 */ public function category() { return $this->belongsTo(Category::class, 'cid', 'id'); } /** * 获取分类名称 */ public function getCateAttr($value, $data) { static $cache = []; $cid = $data['cid'] ?? 0; if (! array_key_exists($cid, $cache)) { $cache[$cid] = Category::where('id', $cid)->value('title'); } return $cache[$cid]; } /** * 站内搜索(兼容 LIKE,避免 FULLTEXT 分词配置差异) */ public static function search(string $keyword, int $page = 1, int $limit = 20) { $kw = $keyword; return self::where('status', 1) ->where(function ($q) use ($kw) { $q->where('title', 'like', '%' . $kw . '%') ->whereOr('description', 'like', '%' . $kw . '%') ->whereOr('keywords', 'like', '%' . $kw . '%'); // 纯字母关键字同时匹配拼音(首字母+全拼),实现拼音搜索 if (preg_match('/^[a-zA-Z]+$/', $kw)) { $q->whereOr('pinyin', 'like', '%' . strtolower($kw) . '%'); } }) ->order('click_count', 'desc') ->paginate(['page' => $page, 'list_rows' => $limit]); } /** * 检测单个链接可达性,返回 HTTP 状态码;不可达返回 false * 先用 HEAD,若返回 >=400(很多站点不支持 HEAD,会误判 403/405)再用 GET 复核一次,降低误杀。 */ public static function checkLink(string $url, int $timeout = 8) { if (!class_exists(\GuzzleHttp\Client::class)) { return false; } $opts = [ 'headers' => ['Member-Agent' => 'Mozilla/5.0 (compatible; HaonavBot/1.0)'], 'allow_redirects' => ['max' => 5], 'stream' => true, // 只取状态码,不下载响应体 ]; // Client 构造不做网络请求,提前实例化以保证下方 catch 中 $client 已定义 $client = new \GuzzleHttp\Client(['timeout' => $timeout, 'verify' => false]); try { $code = $client->request('HEAD', $url, $opts)->getStatusCode(); if ($code >= 400) { // HEAD 被拒,改用 GET 复核(部分站点禁用 HEAD 返回 403/405/501) $code = $client->request('GET', $url, $opts)->getStatusCode(); } return $code; } catch (\GuzzleHttp\Exception\RequestException $e) { if ($e->hasResponse()) { $code = $e->getResponse()->getStatusCode(); if ($code >= 400) { try { return $client->request('GET', $url, $opts)->getStatusCode(); } catch (\Throwable $e2) { return false; } } return $code; } return false; } catch (\Throwable $e) { return false; } } /** * 批量检测全部链接,更新 last_check_at / status_code * @return array [total, ok, dead] */ public static function checkAllLinks(int $timeout = 8): array { return self::checkList(self::select(), $timeout); } /** * 小批量巡检:优先检测「最久未检测」的链接(NULL 最先) * @return array [total, ok, dead] */ public static function checkBatch(int $limit = 20, int $timeout = 5): array { $links = self::order('last_check_at', 'asc')->limit(max(1, $limit))->select(); return self::checkList($links, $timeout); } /** * 对给定链接集合执行检测并落库(含死链自动下线/恢复) * @return array [total, ok, dead, offlined, recovered] */ protected static function checkList($links, int $timeout): array { // 死链自动下线配置(一次检测只读一次),与「导航配置」中 task 分组保持一致 $auto = (int)ConfigureModel::getVal('deadlink_auto', '0') === 1; $threshold = max(1, (int)ConfigureModel::getVal('deadlink_threshold', '2')); $recover = (int)ConfigureModel::getVal('deadlink_recover', '0') === 1; $total = 0; $ok = 0; $dead = 0; $offlined = 0; $recovered = 0; foreach ($links as $link) { $total++; $code = self::checkLink($link->url, $timeout); $link->last_check_at = time(); $isDead = ($code === false) || ($code >= 400); if ($isDead) { $link->status_code = ($code === false) ? 0 : $code; $dead++; // 仅对启用中的链接累计失败并标记死链,避免误动管理员手动禁用/待审核项 if ((int)$link->status === 1) { $link->fail_count = (int)($link->fail_count ?? 0) + 1; $link->dead_at = time(); if ($auto && (int)$link->fail_count >= $threshold) { $link->status = 0; $offlined++; } } } else { $link->status_code = $code; // dead_at 仅检测器下线的链接会带时间戳,手动禁用的为 0 $wasDead = (int)($link->dead_at ?? 0) > 0; $link->fail_count = 0; $link->dead_at = 0; $ok++; // 死链恢复:曾被检测器下线且开启自动恢复 -> 重新启用(手动禁用项 dead_at=0,不会误恢复) if ($recover && $wasDead && (int)$link->status === 0) { $link->status = 1; $recovered++; } } $link->save(); } return ['total' => $total, 'ok' => $ok, 'dead' => $dead, 'offlined' => $offlined, 'recovered' => $recovered]; } /** * 获取网页元信息 */ public static function fetchMeta($url) { libxml_use_internal_errors(true); if (!class_exists(\GuzzleHttp\Client::class)) { throw new \Exception('请先安装 Guzzle: composer require guzzlehttp/guzzle'); } try { $client = new \GuzzleHttp\Client(['timeout' => 10, 'verify' => false]); $response = $client->get($url, [ 'headers' => [ 'Member-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ] ]); $html = (string)$response->getBody(); $headers = $response->getHeader('Content-Type'); } catch (\Exception $e) { return false; } $html = self::toUtf8($html, $headers); $html = preg_replace('/]*charset=[^>]*>/is', '', $html); $html = preg_replace('//i', '', $html, 1); $dom = new \DOMDocument(); $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $xpath = new \DOMXPath($dom); $titleNode = $xpath->query('//title'); $keywordsNode = $xpath->query('//meta[@name="keywords"]'); $descNode = $xpath->query('//meta[@name="description"]'); return [ 'title' => $titleNode->length ? trim($titleNode->item(0)->nodeValue) : '', 'keywords' => $keywordsNode->length ? trim($keywordsNode->item(0)->getAttribute('content')) : '', 'description' => $descNode->length ? trim($descNode->item(0)->getAttribute('content')) : '', ]; } public static function toUtf8($html, array $headers = []) { $charset = null; if (!empty($headers)) { foreach ($headers as $header) { if (preg_match('/charset=([^\s;]+)/i', $header, $match)) { $charset = strtoupper(trim($match[1])); break; } } } if (!$charset) { preg_match('/]*charset=["\']?\s*(gbk|gb2312|big5|iso-8859-1|utf-8)/i', $html, $match); $charset = $match[1] ?? null; } if (!$charset) { $charset = mb_detect_encoding($html, ['UTF-8', 'GBK', 'GB2312', 'BIG5', 'ISO-8859-1']); } if ($charset && strtoupper($charset) !== 'UTF-8') { if (strtoupper($charset) === 'ISO-8859-1') { $html = mb_convert_encoding($html, 'UTF-8', 'GBK'); } else { $html = mb_convert_encoding($html, 'UTF-8', $charset); } } return $html; } }