Files

450 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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('/<meta[^>]*charset=[^>]*>/is', '', $html);
$html = preg_replace('/<head>/i', '<head><meta charset="UTF-8">', $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('/<meta[^>]*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;
}
}