no message

This commit is contained in:
hajimi
2026-06-11 12:15:29 +08:00
parent 10ebe39c30
commit 96efa1d905
5859 changed files with 815501 additions and 5 deletions
@@ -0,0 +1,31 @@
<?php
namespace app\common\command;
use app\common\service\article\ArticleAiCommentService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class ArticleAiComment extends Command
{
protected function configure()
{
$this->setName('article_ai_comment')
->setDescription('资讯AI评论定时任务');
}
protected function execute(Input $input, Output $output)
{
$summary = ArticleAiCommentService::tick();
$output->writeln(sprintf(
'[ArticleAiComment] 虚拟账号 %d 个,新增任务 %d 个,已执行 %d 个',
$summary['virtual_user_count'] ?? 0,
$summary['seeded']['created'] ?? 0,
$summary['processed']['done'] ?? 0
));
return 0;
}
}
@@ -0,0 +1,93 @@
<?php
namespace app\common\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Argument;
class CrawlerCommand extends Command
{
protected function configure()
{
$this->setName('crawler')
->addArgument('action', Argument::REQUIRED, '执行动作: standings/schedule/all/menu')
->setDescription('懂球帝数据爬虫');
}
protected function execute(Input $input, Output $output)
{
$action = $input->getArgument('action');
$allowed = [
'standings',
'schedule',
'all',
'menu',
'news',
'league_news',
'article_content',
'live',
'lottery',
'lottery_draw',
'lottery_draw_force',
'match_data',
'init-db',
'test',
'csl_match',
'nba_match',
'cba_match',
'epl_match',
'bundesliga_match',
'laliga_match',
'seriea_match',
'ligue1_match',
'ucl_match',
'uel_match',
'tennis_match',
'esports_match',
'sports_match',
'truth_social',
'crypto_news',
'lottery_news',
'taiwan_lottery_news',
'hkjc_lottery_news',
'lottery_community',
'nba_news',
'cba_news',
'article_content_fetch',
'fifa_worldcup_news',
'dqd_worldcup',
'match_finish',
'live_detail',
];
if (!in_array($action, $allowed)) {
$output->writeln("<error>不支持的动作: {$action}, 可选: " . implode('/', $allowed) . "</error>");
return;
}
// 文件锁防止同一动作并发执行
$lockFile = runtime_path() . "crawler_{$action}.lock";
$fp = fopen($lockFile, 'w');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
$output->writeln("<comment>[Crawler] {$action} 正在执行中,跳过本次</comment>");
fclose($fp);
return;
}
try {
$crawlerDir = app()->getRootPath() . 'public/dongqiudi-crawler';
$venvPython = $crawlerDir . '/venv/bin/python3';
$python = file_exists($venvPython) ? $venvPython : 'python3';
$cmd = "cd {$crawlerDir} && {$python} main.py {$action} 2>&1";
$output->writeln("<info>[Crawler] 执行: {$cmd}</info>");
$result = shell_exec($cmd);
$output->writeln($result ?: '(无输出)');
} finally {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
}
}
}
+236
View File
@@ -0,0 +1,236 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\command;
use app\common\enum\CrontabEnum;
use app\common\model\CrontabLog;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use Cron\CronExpression;
use think\facade\Console;
use app\common\model\Crontab as CrontabModel;
use app\common\service\crontab\CrontabAlertService;
/**
* 定时任务
* Class Crontab
* @package app\command
*/
class Crontab extends Command
{
protected const CRAWLER_DEFAULT_TIMEOUT = 600;
protected const CRAWLER_ACTION_TIMEOUTS = [
'csl_match' => 180,
'nba_match' => 180,
'cba_match' => 180,
'epl_match' => 180,
'bundesliga_match' => 180,
'laliga_match' => 180,
'seriea_match' => 180,
'ligue1_match' => 180,
'ucl_match' => 180,
'uel_match' => 180,
'tennis_match' => 180,
'esports_match' => 180,
'sports_match' => 180,
'live_detail' => 180,
'match_finish' => 180,
'lottery_draw' => 180,
'lottery_draw_force' => 180,
'article_content' => 600,
'article_content_fetch' => 900,
'league_news' => 900,
'fifa_worldcup_news' => 600,
'dqd_worldcup' => 600,
];
protected function configure()
{
$this->setName('crontab')
->setDescription('定时任务');
}
protected function execute(Input $input, Output $output)
{
$lists = CrontabModel::where('status', CrontabEnum::START)->select()->toArray();
if (empty($lists)) {
return false;
}
$time = time();
foreach ($lists as $item) {
if (empty($item['last_time'])) {
$lastTime = (new CronExpression($item['expression']))
->getNextRunDate()
->getTimestamp();
CrontabModel::where('id', $item['id'])->update([
'last_time' => $lastTime,
]);
continue;
}
$nextTime = (new CronExpression($item['expression']))
->getNextRunDate($item['last_time'])
->getTimestamp();
if ($nextTime > $time) {
// 未到时间,不执行
continue;
}
// 开始执行
self::start($item);
}
}
public static function start($item)
{
if ($item['command'] === 'crawler') {
self::startCrawler($item);
return;
}
$startTime = microtime(true);
$error = '';
$outputStr = '';
try {
ob_start();
$params = explode(' ', $item['params']);
if (is_array($params) && !empty($item['params'])) {
Console::call($item['command'], $params);
} else {
Console::call($item['command']);
}
$outputStr = ob_get_clean() ?: '';
CrontabModel::where('id', $item['id'])->update(['error' => '']);
} catch (\Exception $e) {
$outputStr = ob_get_clean() ?: '';
$error = $e->getMessage();
CrontabModel::where('id', $item['id'])->update([
'error' => $error,
'status' => CrontabEnum::ERROR
]);
} finally {
$endTime = microtime(true);
$useTime = round(($endTime - $startTime), 2);
$maxTime = max($useTime, $item['max_time']);
$logId = 0;
CrontabModel::where('id', $item['id'])->update([
'last_time' => time(),
'time' => $useTime,
'max_time' => $maxTime
]);
try {
$log = CrontabLog::create([
'crontab_id' => $item['id'],
'name' => $item['name'],
'command' => $item['command'],
'params' => $item['params'],
'status' => $error ? 2 : 1,
'output' => $error ?: mb_substr($outputStr ?: '执行完成', 0, 5000),
'elapsed' => $useTime,
'trigger_type' => 1,
]);
$logId = (int) $log->id;
} catch (\Exception $logEx) {
}
try {
if ($error) {
CrontabAlertService::upsertCommandException($item, $error ?: $outputStr, $logId);
} else {
CrontabAlertService::resolveByCrontabId((int) $item['id']);
}
} catch (\Exception $alertEx) {
}
}
}
protected static function startCrawler($item)
{
$crawlerDir = app()->getRootPath() . 'public/dongqiudi-crawler';
$python = is_file($crawlerDir . '/venv/bin/python3') ? $crawlerDir . '/venv/bin/python3' : 'python3';
$action = $item['params'] ?: 'all';
$timeout = self::getCrawlerActionTimeout($action);
$now = time();
self::markStaleCrawlerLogs($item, $timeout, $now);
$running = CrontabLog::where('crontab_id', $item['id'])
->where('status', 0)
->where('create_time', '>', $now - $timeout)
->order('id', 'desc')
->findOrEmpty();
if (!$running->isEmpty()) {
CrontabModel::where('id', $item['id'])->update([
'last_time' => $now,
'error' => '上一次任务仍在执行,已跳过本次',
]);
return;
}
$log = CrontabLog::create([
'crontab_id' => $item['id'],
'name' => $item['name'],
'command' => $item['command'],
'params' => $item['params'],
'status' => 0,
'output' => '',
'elapsed' => 0,
'trigger_type' => 1,
]);
$logId = $log->id;
$crontabId = $item['id'];
$cmd = "cd {$crawlerDir} && nohup {$python} main.py {$action} --log-id={$logId} --crontab-id={$crontabId} > /dev/null 2>&1 &";
\shell_exec($cmd);
CrontabModel::where('id', $item['id'])->update([
'last_time' => time(),
'error' => '',
]);
}
protected static function getCrawlerActionTimeout(string $action): int
{
return self::CRAWLER_ACTION_TIMEOUTS[$action] ?? self::CRAWLER_DEFAULT_TIMEOUT;
}
protected static function markStaleCrawlerLogs(array $item, int $timeout, int $now): void
{
$staleIds = CrontabLog::where('crontab_id', $item['id'])
->where('status', 0)
->where('create_time', '<=', $now - $timeout)
->column('id');
if (empty($staleIds)) {
return;
}
$message = sprintf('执行超过 %d 秒未结束,已由调度器标记超时', $timeout);
CrontabLog::whereIn('id', $staleIds)->update([
'status' => 2,
'output' => $message,
'elapsed' => $timeout,
]);
CrontabModel::where('id', $item['id'])->update([
'error' => $message,
]);
try {
CrontabAlertService::upsertCommandException($item, $message, (int) end($staleIds));
} catch (\Exception $e) {
}
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace app\common\command;
use app\common\model\ai\AiKbSyncJob;
use app\common\service\ai\KbService;
use app\common\service\ai\KbSyncService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
class KbConsume extends Command
{
protected function configure()
{
$this->setName('kb:consume')
->setDescription('消费AI知识库同步任务')
->addOption('batch', null, Option::VALUE_OPTIONAL, '批量处理数量', 100);
}
protected function execute(Input $input, Output $output)
{
$batch = max(1, (int) $input->getOption('batch'));
KbSyncService::markLotteryBackfillJobs($batch);
KbSyncService::markMatchBackfillJobs($batch);
$jobs = AiKbSyncJob::where('status', AiKbSyncJob::STATUS_PENDING)
->where('scheduled_at', '<=', time())
->order('priority', 'asc')
->order('id', 'asc')
->limit($batch)
->select();
$success = 0;
$failed = 0;
foreach ($jobs as $job) {
$job->status = AiKbSyncJob::STATUS_PROCESSING;
$job->update_time = time();
$job->save();
try {
if ($job->action === AiKbSyncJob::ACTION_DELETE) {
KbService::deleteDocument($job->domain, $job->subtype, (int) $job->source_id);
$result = ['success' => true];
} else {
$result = KbService::upsertDocument($job->domain, $job->subtype, (int) $job->source_id);
}
if (!empty($result['success'])) {
$job->status = AiKbSyncJob::STATUS_SUCCESS;
$job->processed_at = time();
$job->error_msg = '';
$success++;
} else {
$job->status = AiKbSyncJob::STATUS_FAILED;
$job->retry_count = (int) $job->retry_count + 1;
$job->error_msg = $result['error'] ?? 'unknown';
$failed++;
}
} catch (\Throwable $e) {
$job->status = AiKbSyncJob::STATUS_FAILED;
$job->retry_count = (int) $job->retry_count + 1;
$job->error_msg = $e->getMessage();
$failed++;
}
$job->update_time = time();
$job->save();
}
$output->writeln(sprintf('[kb:consume] success=%d failed=%d jobs=%d', $success, $failed, count($jobs)));
return 0;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace app\common\command;
use app\common\service\ai\KbService;
use app\common\service\ai\KbSyncService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\facade\Db;
class KbRebuild extends Command
{
protected function configure()
{
$this->setName('kb:rebuild')
->setDescription('重建AI知识库文档')
->addOption('domain', null, Option::VALUE_OPTIONAL, 'article|post|lottery|match|all', 'all');
}
protected function execute(Input $input, Output $output)
{
$domain = (string) $input->getOption('domain');
$targets = $domain === 'all' ? ['article', 'post', 'lottery', 'match'] : [$domain];
$summary = ['success' => 0, 'failed' => 0];
foreach ($targets as $target) {
foreach ($this->yieldRows($target) as $row) {
$result = KbService::upsertDocument($row['domain'], $row['subtype'], (int) $row['source_id']);
if (!empty($result['success'])) {
$summary['success']++;
} else {
$summary['failed']++;
$output->writeln(sprintf(
'[failed] %s/%s/%d %s',
$row['domain'],
$row['subtype'],
$row['source_id'],
$result['error'] ?? 'unknown'
));
}
}
}
KbSyncService::markLotteryBackfillJobs();
$output->writeln(sprintf('[kb:rebuild] success=%d failed=%d', $summary['success'], $summary['failed']));
return 0;
}
private function yieldRows(string $domain): \Generator
{
if ($domain === 'article') {
yield from $this->yieldTableRows('article', 'article', 'article');
return;
}
if ($domain === 'post') {
yield from $this->yieldTableRows('community_post', 'post', 'post');
return;
}
if ($domain === 'lottery') {
yield from $this->yieldTableRows('lottery_draw_result', 'lottery', 'draw_result');
yield from $this->yieldTableRows('lottery_ai_analysis', 'lottery', 'ai_history');
return;
}
if ($domain === 'match') {
yield from $this->yieldTableRows('match', 'match', 'event');
}
}
private function yieldTableRows(string $table, string $domain, string $subtype): \Generator
{
$lastId = 0;
$chunkSize = 5000;
while (true) {
$ids = Db::name($table)
->where('id', '>', $lastId)
->order('id', 'asc')
->limit($chunkSize)
->column('id');
if (empty($ids)) {
break;
}
foreach ($ids as $id) {
$lastId = (int) $id;
yield [
'domain' => $domain,
'subtype' => $subtype,
'source_id' => $lastId,
];
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
// +----------------------------------------------------------------------
// | likeadmin快速开发前后端分离管理后台(PHP版)
// +----------------------------------------------------------------------
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
// | 开源版本可自由商用,可去除界面版权logo
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
// | github下载:https://github.com/likeshop-github/likeadmin
// | 访问官网:https://www.likeadmin.cn
// | likeadmin团队 版权所有 拥有最终解释权
// +----------------------------------------------------------------------
// | author: likeadminTeam
// +----------------------------------------------------------------------
namespace app\common\command;
use app\common\enum\PayEnum;
use app\common\enum\RefundEnum;
use app\common\model\recharge\RechargeOrder;
use app\common\model\refund\RefundLog;
use app\common\model\refund\RefundRecord;
use app\common\service\pay\WeChatPayService;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Log;
class QueryRefund extends Command
{
protected function configure()
{
$this->setName('query_refund')
->setDescription('订单退款状态处理');
}
protected function execute(Input $input, Output $output)
{
try {
// 查找退款中的退款记录(微信,支付宝支付)
$refundRecords = (new RefundLog())->alias('l')
->join('refund_record r', 'r.id = l.record_id')
->field([
'l.id' => 'log_id', 'l.sn' => 'log_sn',
'r.id' => 'record_id', 'r.order_id', 'r.sn' => 'record_sn', 'r.order_type'
])
->where(['l.refund_status' => RefundEnum::REFUND_ING])
->select()->toArray();
if (empty($refundRecords)) {
return false;
}
// 分别处理各个类型订单
$rechargeRecords = array_filter($refundRecords, function ($item) {
return $item['order_type'] == RefundEnum::ORDER_TYPE_RECHARGE;
});
if (!empty($rechargeRecords)) {
$this->handleRechargeOrder($rechargeRecords);
}
return true;
} catch (\Exception $e) {
Log::write('订单退款状态查询失败,失败原因:' . $e->getMessage());
return false;
}
}
/**
* @notes 处理充值订单
* @param $refundRecords
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/3/1 15:55
*/
public function handleRechargeOrder($refundRecords)
{
$orderIds = array_unique(array_column($refundRecords, 'order_id'));
$Orders = RechargeOrder::whereIn('id', $orderIds)->column('*', 'id');
foreach ($refundRecords as $record) {
if (!isset($Orders[$record['order_id']])) {
continue;
}
$order = $Orders[$record['order_id']];
if (!in_array($order['pay_way'], [PayEnum::WECHAT_PAY, PayEnum::ALI_PAY])) {
continue;
}
$this->checkReFundStatus([
'record_id' => $record['record_id'],
'log_id' => $record['log_id'],
'log_sn' => $record['log_sn'],
'pay_way' => $order['pay_way'],
'order_terminal' => $order['order_terminal'],
]);
}
}
/**
* @notes 校验退款状态
* @param $refundData
* @return bool
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/3/1 15:54
*/
public function checkReFundStatus($refundData)
{
$result = null;
switch ($refundData['pay_way']) {
case PayEnum::WECHAT_PAY:
$result = self::checkWechatRefund($refundData['order_terminal'], $refundData['log_sn']);
break;
}
if (is_null($result)) {
return false;
}
if (true === $result) {
$this->updateRefundSuccess($refundData['log_id'], $refundData['record_id']);
} else {
$this->updateRefundMsg($refundData['log_id'], $result);
}
return true;
}
/**
* @notes 查询微信支付退款状态
* @param $orderTerminal
* @param $refundLogSn
* @return bool|string|null
* @throws \EasyWeChat\Kernel\Exceptions\InvalidArgumentException
* @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
* @author 段誉
* @date 2023/3/1 15:47
*/
public function checkWechatRefund($orderTerminal, $refundLogSn)
{
// 根据商户退款单号查询退款
$result = (new WeChatPayService($orderTerminal))->queryRefund($refundLogSn);
if (!empty($result['status']) && $result['status'] == 'SUCCESS') {
return true;
}
if (!empty($result['code']) || !empty($result['message'])) {
return '微信:' . $result['code'] . '-' . $result['message'];
}
return null;
}
/**
* @notes 更新记录为成功
* @param $logId
* @param $recordId
* @author 段誉
* @date 2023/3/1 15:38
*/
public function updateRefundSuccess($logId, $recordId)
{
// 更新日志
RefundLog::update([
'id' => $logId,
'refund_status' => RefundEnum::REFUND_SUCCESS,
]);
// 更新记录
RefundRecord::update([
'id' => $recordId,
'refund_status' => RefundEnum::REFUND_SUCCESS,
]);
}
/**
* @notes 更新退款信息
* @param $logId
* @param $msg
* @author 段誉
* @date 2023/3/1 15:47
*/
public function updateRefundMsg($logId, $msg)
{
// 更新日志
RefundLog::update([
'id' => $logId,
'refund_msg' => $msg,
]);
}
}
@@ -0,0 +1,65 @@
<?php
namespace app\common\command;
use app\common\model\user\User;
use app\common\model\SmsLog;
use think\console\Command;
use think\console\Input;
use think\console\Output;
/**
* 短信验证定时任务
* 轮询有手机号但未验证(is_bind_mobile=0)的用户,
* 从 la_sms_upload 反向校验是否有该手机号的任意上传记录,
* 若匹配则标记 is_bind_mobile=1
*/
class SmsVerifyCommand extends Command
{
protected function configure()
{
$this->setName('sms_verify')
->setDescription('轮询未验证手机号用户,从上传记录反向校验并更新绑定状态');
}
protected function execute(Input $input, Output $output)
{
$users = User::where('mobile', '<>', '')
->where('is_bind_mobile', 0)
->field('id, mobile')
->select();
if ($users->isEmpty()) {
$output->writeln('<info>[SmsVerify] 无待验证用户</info>');
return;
}
$output->writeln('<info>[SmsVerify] 待验证用户: ' . $users->count() . '</info>');
$updated = 0;
foreach ($users as $user) {
$mobile = $user->mobile;
// 查询 la_sms_upload 是否有该手机号的任意上传记录(兼容+86前缀)
$record = SmsLog::where(function ($query) use ($mobile) {
$query->where('phone', $mobile)
->whereOr('phone', '+86' . $mobile)
->whereOr('phone', '86' . $mobile);
})
->order('sms_time', 'desc')
->findOrEmpty();
if (!$record->isEmpty()) {
if ((int)$record->status === 0) {
$record->status = 1;
$record->save();
}
User::where('id', $user->id)->update(['is_bind_mobile' => 1]);
$updated++;
$output->writeln("<info> ✓ 用户 {$user->id} ({$mobile}) 已验证</info>");
}
}
$output->writeln("<info>[SmsVerify] 完成,更新 {$updated} 个用户</info>");
}
}
@@ -0,0 +1,129 @@
<?php
namespace app\common\command;
use app\api\logic\ArticleLogic;
use app\common\enum\YesNoEnum;
use app\common\model\article\Article;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class WorldcupArticleTranslate extends Command
{
protected function configure()
{
$this->setName('worldcup_article_translate')
->setDescription('预热世界杯英文资讯翻译缓存');
}
protected function execute(Input $input, Output $output)
{
$articles = Article::field('id,cid,title,desc,content,ext,is_show,delete_time')
->where('cid', 22)
->where('is_show', YesNoEnum::YES)
// 与前台世界杯资讯列表保持一致,只预热用户最先看到的 50 条。
->order('published_at', 'desc')
->order('id', 'desc')
->limit(50)
->select()
->toArray();
if (empty($articles)) {
$output->writeln('<comment>[WorldcupTranslate] 没有找到可处理的世界杯文章</comment>');
return 0;
}
$translated = 0;
$skippedExist = 0;
$skippedNonEnglish = 0;
$failed = 0;
foreach ($articles as $article) {
$ext = $this->decodeExt($article['ext'] ?? []);
if (!empty($ext['translated_content'])) {
$skippedExist++;
continue;
}
if (!$this->isProbablyEnglish($article)) {
$skippedNonEnglish++;
continue;
}
try {
$result = ArticleLogic::autoTranslateArticle($article);
if ($result !== '') {
$translated++;
$output->writeln(sprintf('[WorldcupTranslate] 已翻译文章 #%d %s', $article['id'], $article['title'] ?? ''));
} else {
$failed++;
$output->writeln(sprintf('<comment>[WorldcupTranslate] 翻译失败或无结果 #%d %s</comment>', $article['id'], $article['title'] ?? ''));
}
} catch (\Throwable $e) {
$failed++;
$output->writeln(sprintf('<error>[WorldcupTranslate] 异常 #%d %s - %s</error>', $article['id'], $article['title'] ?? '', $e->getMessage()));
}
}
$output->writeln(sprintf(
'[WorldcupTranslate] 完成,翻译 %d 条,已存在 %d 条,跳过非英文 %d 条,失败 %d 条',
$translated,
$skippedExist,
$skippedNonEnglish,
$failed
));
return 0;
}
private function isProbablyEnglish(array $article): bool
{
$text = trim(implode(' ', [
(string) ($article['title'] ?? ''),
(string) ($article['desc'] ?? ''),
$this->normalizeText((string) ($article['content'] ?? '')),
]));
if ($text === '') {
return false;
}
if (preg_match('/[\x{4e00}-\x{9fff}]/u', $text)) {
return false;
}
$letterCount = preg_match_all('/[A-Za-z]/', $text);
$wordCount = preg_match_all('/\b[A-Za-z]{2,}\b/', $text);
// 世界杯资讯里不少正文并不长,阈值过高会误杀正常英文稿件。
return $letterCount >= 30 && $wordCount >= 5;
}
private function normalizeText(string $content): string
{
if ($content === '') {
return '';
}
$content = strip_tags($content);
$content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$content = preg_replace('/\s+/u', ' ', $content);
return trim($content);
}
private function decodeExt($ext): array
{
if (is_array($ext)) {
return $ext;
}
if (is_string($ext) && $ext !== '') {
$data = json_decode($ext, true);
return is_array($data) ? $data : [];
}
return [];
}
}