450 lines
16 KiB
PHP
450 lines
16 KiB
PHP
<?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;
|
||
}
|
||
}
|