chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
<?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 think\facade\Cache;
use ywxapp\model\BaseModel;
/**
* 广告位模型
* 支持多广告位(首页顶部/底部、详情页内联、侧边栏),可投放图片广告或自定义代码(联盟广告)。
* 表在首次访问时由 BaseModel 自愈引擎建立,无需重跑 install.sql。
*/
class Ad extends BaseModel
{
// 广告位标识
const SLOT_HOME_TOP = 'home_top';
const SLOT_HOME_BOTTOM = 'home_bottom';
const SLOT_DETAIL_INLINE = 'detail_inline';
const SLOT_SIDEBAR = 'sidebar';
// 类型:1=图片广告 2=代码广告
const TYPE_IMAGE = 1;
const TYPE_CODE = 2;
/**
* 广告位字典(标识 => 中文名)
*/
public static function slots(): array
{
return [
self::SLOT_HOME_TOP => '首页顶部',
self::SLOT_HOME_BOTTOM => '首页底部',
self::SLOT_DETAIL_INLINE => '详情页内联',
self::SLOT_SIDEBAR => '侧边栏',
];
}
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_ads',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 取某广告位当前生效的广告列表(状态启用 + 在生效时间范围内)
*/
public static function getBySlot(string $slot): array
{
$now = time();
return self::where('slot', $slot)
->where('status', 1)
->where(function ($q) use ($now) {
$q->whereNull('start_at')->whereOr('start_at', '<=', $now);
})
->where(function ($q) use ($now) {
$q->whereNull('end_at')->whereOr('end_at', '>=', $now);
})
->order('sort', 'desc')
->order('id', 'desc')
->select()
->toArray();
}
/**
* 所有广告位当前生效广告(按 slot 分组),带 60s 缓存,供前台统一 assign
*/
public static function allActiveBySlot(): array
{
$cacheKey = 'haonav_ads_all';
$out = Cache::get($cacheKey);
if (is_array($out)) {
return $out;
}
$out = [];
foreach (self::slots() as $k => $v) {
$out[$k] = self::getBySlot($k);
}
Cache::set($cacheKey, $out, 60);
return $out;
}
/**
* 清除广告缓存(后台增删改时调用)
*/
public static function clearCache(): void
{
Cache::delete('haonav_ads_all');
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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;
/**
* 友链/广告 自助申请模型
* 访客在前台提交收录或广告合作申请,后台审核;通过的友链可一键写入 Links。
* 表在首次访问时由 BaseModel 自愈引擎 自愈建立,无需重跑 install.sql。
*/
class Apply extends BaseModel
{
// 申请类型
const TYPE_LINK = 1; // 友链/收录申请
const TYPE_AD = 2; // 广告合作申请
// 审核状态
const STATUS_PENDING = 0; // 待审核
const STATUS_APPROVED = 1; // 已通过
const STATUS_REJECTED = 2; // 已拒绝
/**
* 类型字典
*/
public static function types(): array
{
return [
self::TYPE_LINK => '收录/友链',
self::TYPE_AD => '广告合作',
];
}
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_apply',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 同一 IP 限频:窗口期内最多 N 条(默认 1 小时 5 条)
*/
public static function ipOverLimit(string $ip, int $max = 5, int $window = 3600): bool
{
$count = self::where('ip', $ip)->where('create_at', '>=', time() - $window)->count();
return $count >= $max;
}
/**
* 同 URL 去重:待审/已通过中已存在同地址申请则拒收
*/
public static function urlExists(string $url): bool
{
return self::where('url', $url)->whereIn('status', [self::STATUS_PENDING, self::STATUS_APPROVED])->count() > 0;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?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;
class Category extends BaseModel
{
protected function getOptions(): array
{
// 所有的参数配置统一返回
return [
'strict' => false,
'name' => 'haonav_category',
// 'connection' => '',
// 'query' => [],
// 'type' => [],
// 'hidden' => [],
// 'visible' => [],
// 'append' => [],
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
public function links()
{
return $this->hasMany(Links::class, 'cid', 'id');
}
/**
* 子分类(二级及更深层次)
*/
public function children()
{
return $this->hasMany(self::class, 'pid', 'id');
}
/**
* 删除前保护:存在子分类或下属链接时禁止删除,避免产生孤儿数据
* @param \think\Model $model
* @throws \think\Exception
*/
public static function onBeforeDelete($model)
{
$childCount = self::where('pid', $model->id)->count();
if ($childCount > 0) {
throw new \think\Exception('该分类下还存在 ' . $childCount . ' 个子分类,请先处理子分类');
}
$linkCount = \addon\haonav\model\Links::where('cid', $model->id)->count();
if ($linkCount > 0) {
throw new \think\Exception('该分类下还存在 ' . $linkCount . ' 个链接,请先移走或删除这些链接');
}
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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;
class Configure extends BaseModel
{
protected function getOptions(): array
{
// 所有的参数配置统一返回
return [
'strict' => false,
'name' => 'haonav_config',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 读取单个配置值
* @param string $name
* @param mixed $default
* @return mixed
*/
public static function getVal(string $name, $default = '')
{
static $cache = [];
if (array_key_exists($name, $cache)) {
return $cache[$name];
}
$val = self::where('name', $name)->value('value');
$val = $val === null ? $default : $val;
$cache[$name] = $val;
return $val;
}
/**
* 读取全部配置为 name => value 数组
* @return array
*/
public static function getAll(): array
{
return self::column('value', 'name');
}
/**
* 老库结构自愈:早期 install.sql 的 config 表 id 无 PRIMARY KEY/AUTO_INCREMENT
* create() 不带 id 会报 1364 Field 'id' doesn't have a default value,此处运行时修复。
*/
protected static function ensurePk(): void
{
static $done = false;
if ($done) {
return;
}
self::ensureAutoIncrementPk('wxapp_haonav_config');
$done = true;
}
/**
* 返利配置自愈:老库安装时 install.sql 尚未含这 3 行,访问配置页时补种,
* 保证后台「返利」分组始终可配置(INSERT 幂等,按 name 判重)。
*/
public static function ensureRebateConfig(): void
{
self::ensurePk();
$defaults = [
'enable_rebate' => ['返利', '开启返利PID', '开启后,命中适用域名的链接点击将自动拼接返利PID', 'radio', '0', '1,0'],
'rebate_param' => ['返利', '返利参数名', '拼接在网址后的参数名,默认 pid', 'string', 'pid', ''],
'rebate_domains' => ['返利', '返利适用域名', '仅这些域名(含子域名)会拼接返利PID,逗号或换行分隔,如 taobao.com,jd.com,pinduoduo.com', 'textarea', 'taobao.com,jd.com,pinduoduo.com', ''],
];
foreach ($defaults as $name => $cfg) {
if (self::where('name', $name)->count() === 0) {
self::create([
'name' => $name,
'group' => $cfg[0],
'title' => $cfg[1],
'tip' => $cfg[2],
'type' => $cfg[3],
'value' => $cfg[4],
'content' => $cfg[5],
]);
}
}
}
/**
* 死链自动下线配置自愈:老库安装时 install.sql 尚未含这 3 行,访问配置页时补种,
* 保证后台「导航配置」的 task 分组始终可配置(INSERT 幂等,按 name 判重)。
*/
public static function ensureDeadlinkConfig(): void
{
self::ensurePk();
$defaults = [
'deadlink_auto' => ['task', '死链自动下线', '连续检测失败达阈值后,自动将链接置为禁用(下线)', 'radio', '0', '1,0'],
'deadlink_threshold' => ['task', '下线阈值(连续失败次数)', '达到该连续失败次数才下线,避免单次网络抖动误杀(建议2)', 'number', '2', ''],
'deadlink_recover' => ['task', '死链恢复自动上线', '链接重新可达时自动恢复为启用(仅对检测器自动下线的链接生效)', 'radio', '0', '1,0'],
];
foreach ($defaults as $name => $cfg) {
if (self::where('name', $name)->count() === 0) {
self::create([
'name' => $name,
'group' => $cfg[0],
'title' => $cfg[1],
'tip' => $cfg[2],
'type' => $cfg[3],
'value' => $cfg[4],
'content' => $cfg[5],
]);
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<?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;
/**
* 用户云端收藏
* @mixin \think\Model
*/
class Favorite extends BaseModel
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_favorites',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => false,
'dateFormat' => 'Y-m-d H:i:s',
];
}
}
+70
View File
@@ -0,0 +1,70 @@
<?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;
/**
* 用户收藏夹分享配置(一个用户一条)
*
* 用户可把「我的导航」生成一个只读分享链接,他人凭 token 访问 shared 页浏览,
* enabled=1 才对外可见;token 可重置(旧链接立即失效)。
*
* @mixin \think\Model
*/
class FavoriteShare extends BaseModel
{
protected function getOptions(): array
{
return [
'strict' => false,
'name' => 'haonav_favorite_shares',
'autoRelation' => [],
'createTime' => 'create_at',
'updateTime' => 'update_at',
'dateFormat' => 'Y-m-d H:i:s',
];
}
/**
* 生成一个随机分享令牌(16位十六进制)
*/
public static function genToken(): string
{
try {
return bin2hex(random_bytes(8));
} catch (\Throwable $e) {
return substr(md5(uniqid((string)mt_rand(), true)), 0, 16);
}
}
/**
* 取当前用户的分享配置,无则创建一条(默认关闭、已生成 token)
*/
public static function forUser(int $uid): FavoriteShare
{
$row = self::where('user_id', $uid)->find();
if (!$row) {
$row = new self();
$row->user_id = $uid;
$row->token = self::genToken();
$row->title = '我的导航收藏夹';
$row->enabled = 0;
$row->views = 0;
$row->save();
} elseif (empty($row->token)) {
$row->token = self::genToken();
$row->save();
}
return $row;
}
}
+449
View File
@@ -0,0 +1,449 @@
<?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;
}
}