Files
UrlNav/Action.php

2621 lines
92 KiB
PHP
Raw Normal View History

2026-02-23 20:15:55 +08:00
<?php
if (!defined('__TYPECHO_ROOT_DIR__')) exit;
/**
* UrlNav 动作处理器
*/
class UrlNav_Action extends Typecho_Widget implements Widget_Interface_Do
{
/**
* 数据库连接
*/
private $db;
/**
* 构造函数
*/
public function __construct($request, $response, $params = NULL)
{
parent::__construct($request, $response, $params);
$this->db = UrlNav_Plugin::getDbConnection();
}
public function action()
{
// 检查管理员权限 - Collection插件就是这样写的
$user = Typecho_Widget::widget('Widget_User');
if (!$user->hasLogin() || !$user->pass('administrator', true)) {
$this->response->throwJson(array(
'success' => false,
'message' => '无权限访问'
));
return;
}
// 确保返回JSON格式 - Collection插件就是这样写的
$this->response->setContentType('application/json');
// 获取do参数 - Collection插件就是这样写的
$do = $this->request->get('do');
if (!$do) {
$do = $this->request->isPost() ? $this->request->get('do') : null;
}
switch ($do) {
case 'addCategory':
$this->addCategory();
break;
case 'updateCategory':
$this->updateCategory();
break;
case 'deleteCategory':
$this->deleteCategory();
break;
case 'getCategory':
$this->getCategory();
break;
case 'getAllCategories':
$this->getAllCategories();
break;
case 'addUrl':
$this->addUrl();
break;
case 'updateUrl':
$this->updateUrl();
break;
case 'deleteUrl':
$this->deleteUrl();
break;
case 'getUrl':
$this->getUrl();
break;
case 'getAllUrls':
$this->getAllUrls();
break;
case 'getRssRefreshLogs': // 🔴 新增获取详细RSS地址
$this->getRssRefreshLogs();
break;
case 'batchDeleteUrls':
$this->batchDeleteUrls();
break;
case 'batchDeleteCategories':
$this->batchDeleteCategories();
break;
case 'test':
$this->test();
break;
case 'fetchRss':
$this->fetchRss();
break;
case 'getRssFeeds':
$this->getRssFeeds();
break;
case 'cleanRssCache':
$this->cleanRssCache();
break;
case 'addFavorite':
$this->addFavorite();
break;
case 'removeFavorite':
$this->removeFavorite();
break;
case 'getFavorites':
$this->getFavorites();
break;
case 'getFavoriteStats':
$this->getFavoriteStats();
break;
case 'checkFavorite':
$this->checkFavorite();
break;
case 'getCacheStats':
$this->getCacheStats();
break;
case 'getCronLogs':
$this->getCronLogs();
break;
case 'getCronStats':
$this->getCronStats();
break;
case 'getRefreshStats':
$this->getRefreshStats();
break;
case 'exportOpml':
$this->exportOpml();
break;
case 'importOpml':
$this->importOpml();
break;
case 'unlockCron':
$this->unlockCron();
break;
case 'checkStatus':
$this->checkStatus();
break;
case 'checkSingleStatus':
$this->checkSingleStatus();
break;
case 'getRssRefreshStatus':
$this->getRssRefreshStatus();
break;
case 'getRssRefreshStats':
$this->getRssRefreshStats();
break;
case 'getStatusStats':
$this->getStatusStats();
break;
case 'getRssCronStats':
$this->getRssCronStats();
break;
case 'getStatusCronStats':
$this->getStatusCronStats();
break;
// ================ 新增:失败网址相关方法 ================
case 'getRefreshLogs': // 获取刷新日志
$this->getRefreshLogs();
break;
case 'getRecentRefreshStats': // 获取最近刷新统计
$this->getRecentRefreshStats();
break;
case 'getAllFailedUrls': // 查看所有失败网址
$this->getAllFailedUrls();
break;
case 'retryRssUrl': // 重试单个失败网址
$this->retryRssUrl();
break;
case 'ignoreFailedUrl': // 忽略失败网址
$this->ignoreFailedUrl();
break;
case 'enableFailedUrl': // 启用已停用网址
$this->enableFailedUrl();
break;
case 'retryAllFailedUrls': // 重试所有失败网址
$this->retryAllFailedUrls();
break;
case 'exportFailedUrls': // 导出失败网址列表
$this->exportFailedUrls();
break;
case 'exportSuccessUrls':
$this->exportSuccessUrls();
break;
// ================ 结束新增 ================
default:
$this->response->throwJson(array(
'success' => false,
'message' => '无效的操作'
));
}
}
// 在 action() 方法之后,但在类结束之前添加以下方法
/**
* 添加收藏
*/
private function addFavorite()
{
try {
$feedId = $this->request->get('feed_id');
if (!$feedId || !is_numeric($feedId)) {
$this->response->throwJson(['success' => false, 'message' => '文章ID不能为空或格式错误']);
return;
}
$result = UrlNav_Plugin::addFavorite($feedId, 0);
$this->response->throwJson($result);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '添加收藏失败: ' . $e->getMessage()
]);
}
}
/**
* 取消收藏
*/
private function removeFavorite()
{
try {
$feedId = $this->request->get('feed_id');
if (!$feedId || !is_numeric($feedId)) {
$this->response->throwJson(['success' => false, 'message' => '文章ID不能为空或格式错误']);
return;
}
$result = UrlNav_Plugin::removeFavorite($feedId, 0);
$this->response->throwJson($result);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '取消收藏失败: ' . $e->getMessage()
]);
}
}
/**
* 获取收藏列表
*/
private function getFavorites()
{
try {
$page = $this->request->get('page', 1);
$pageSize = $this->request->get('pageSize', 20);
$search = $this->request->get('search', '');
$result = UrlNav_Plugin::getFavorites(0, $page, $pageSize, $search);
$this->response->throwJson($result);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '获取收藏列表失败: ' . $e->getMessage()
]);
}
}
/**
* 获取收藏统计
*/
private function getFavoriteStats()
{
try {
$result = UrlNav_Plugin::getFavoriteStats(0);
$this->response->throwJson([
'success' => true,
'data' => $result
]);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '获取收藏统计失败: ' . $e->getMessage()
]);
}
}
/**
* 检查是否已收藏
*/
private function checkFavorite()
{
try {
$feedId = $this->request->get('feed_id');
if (!$feedId || !is_numeric($feedId)) {
$this->response->throwJson(['success' => false, 'message' => '文章ID不能为空或格式错误']);
return;
}
$isFavorite = UrlNav_Plugin::isFavorite($feedId, 0);
$this->response->throwJson([
'success' => true,
'is_favorite' => $isFavorite
]);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '检查收藏状态失败: ' . $e->getMessage()
]);
}
}
/**
* 获取RSS刷新详细日志
*/
public function getRssRefreshLogs()
{
try {
$limit = $this->request->get('limit', 10);
$page = $this->request->get('page', 1);
$db = UrlNav_Plugin::getDbConnection();
// 计算分页
$offset = ($page - 1) * $limit;
// 获取refresh_log数据RSS刷新详情
$sql = "SELECT * FROM urlnav_refresh_log
WHERE cron_type = 'rss'
ORDER BY refresh_time DESC
LIMIT ? OFFSET ?";
$stmt = $db->prepare($sql);
$stmt->execute([$limit, $offset]);
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 解析details字段中的RSS地址
foreach ($logs as &$log) {
if (!empty($log['details'])) {
$details = @json_decode($log['details'], true);
if ($details) {
$log['details_parsed'] = $details;
// 提取RSS地址
if (isset($details['success_rss_urls'])) {
$log['success_rss_urls'] = $details['success_rss_urls'];
}
if (isset($details['failed_rss_urls'])) {
$log['failed_rss_urls'] = $details['failed_rss_urls'];
}
}
}
// 使用message字段已经包含RSS地址信息
$log['formatted_message'] = $log['message'] ??
"刷新完成:成功 " . ($log['success_count'] ?? 0) . " 个,失败 " .
(($log['url_count'] ?? 0) - ($log['success_count'] ?? 0)) . "";
}
// 获取总数
$countStmt = $db->prepare("SELECT COUNT(*) as total FROM urlnav_refresh_log WHERE cron_type = 'rss'");
$countStmt->execute();
$totalResult = $countStmt->fetch(PDO::FETCH_ASSOC);
$total = $totalResult['total'] ?? 0;
$this->response->throwJson([
'success' => true,
'data' => $logs,
'total' => $total,
'page' => $page,
'limit' => $limit,
'totalPages' => ceil($total / $limit)
]);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '获取详细日志失败: ' . $e->getMessage()
]);
}
}
/**
* 获取RSS刷新状态
*/
public function getRssRefreshStatus()
{
try {
$stats = UrlNav_Plugin::getRssRefreshStatus();
$this->response->throwJson(array(
'success' => true,
'data' => $stats
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取刷新状态失败: ' . $e->getMessage()
));
}
}
/**
* RSS定时任务接口无需登录
*/
public function rssCron()
{
// 设置响应头避免502错误
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
}
// 获取密钥
$secret = $this->request->get('secret');
try {
$result = UrlNav_Plugin::executePublicRssCron($secret);
echo json_encode($result);
exit;
} catch (Exception $e) {
$errorResponse = array(
'success' => false,
'message' => 'RSS定时任务执行失败: ' . $e->getMessage(),
'timestamp' => time()
);
echo json_encode($errorResponse);
exit;
}
}
/**
* 状态检查定时任务接口(无需登录)
*/
public function statusCron()
{
// 设置响应头避免502错误
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
}
// 获取密钥
$secret = $this->request->get('secret');
try {
$result = UrlNav_Plugin::executePublicStatusCron($secret);
echo json_encode($result);
exit;
} catch (Exception $e) {
$errorResponse = array(
'success' => false,
'message' => '状态检查定时任务执行失败: ' . $e->getMessage(),
'timestamp' => time()
);
echo json_encode($errorResponse);
exit;
}
}
/**
* 手动解锁定时任务
*/
public function unlockCron()
{
$this->response->setContentType('application/json');
try {
$type = $this->request->get('type', 'rss');
// 验证用户权限(解锁需要管理员权限)
$user = Typecho_Widget::widget('Widget_User');
if (!$user->hasLogin() || !$user->pass('administrator', true)) {
$this->response->throwJson(array(
'success' => false,
'message' => '无权限解锁定时任务'
));
return;
}
$result = UrlNav_Plugin::unlockCron($type);
$this->response->throwJson($result);
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '解锁失败: ' . $e->getMessage()
));
}
}
/**
* 测试方法 - 完全按照Collection插件写法
*/
public function test()
{
$this->response->throwJson(array(
'success' => true,
'message' => 'UrlNav插件Action工作正常',
'timestamp' => time()
));
}
/**
* 清理RSS缓存
*/
public function cleanRssCache()
{
try {
$cleanedCount = UrlNav_Plugin::cleanAllRssCache();
$this->response->throwJson(array(
'success' => true,
'message' => '清理完成',
'cleaned_count' => $cleanedCount
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '清理失败: ' . $e->getMessage()
));
}
}
/**
* 获取缓存统计信息
*/
public function getCacheStats()
{
try {
$stats = UrlNav_Plugin::getCacheStats();
$this->response->throwJson(array(
'success' => true,
'data' => $stats
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取统计信息失败: ' . $e->getMessage()
));
}
}
/**
* 获取刷新统计信息
*/
public function getRefreshStats()
{
try {
$limit = $this->request->get('limit', 10);
$cronType = $this->request->get('cron_type', 'rss');
$stats = UrlNav_Plugin::getRefreshStats($cronType, $limit);
$this->response->throwJson(array(
'success' => true,
'data' => $stats
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取刷新统计失败: ' . $e->getMessage()
));
}
}
/**
* 获取定时任务日志
*/
public function getCronLogs()
{
try {
$limit = $this->request->get('limit', 20);
$type = $this->request->get('type');
$logs = UrlNav_Plugin::getCronLogs($type, $limit);
$this->response->throwJson(array(
'success' => true,
'data' => $logs
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取定时任务日志失败: ' . $e->getMessage()
));
}
}
/**
* 获取定时任务统计
*/
public function getCronStats()
{
try {
$type = $this->request->get('type');
$stats = UrlNav_Plugin::getCronStats($type);
$this->response->throwJson(array(
'success' => true,
'data' => $stats
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取定时任务统计失败: ' . $e->getMessage()
));
}
}
/**
* 获取RSS定时任务统计
*/
public function getRssCronStats()
{
try {
$stats = UrlNav_Plugin::getRssCronStats();
// 获取最近一次的失败网址24小时内
$recentFailures = $this->getRecentFailedUrls();
$this->response->throwJson(array(
'success' => true,
'data' => array_merge($stats, [
'recent_failures' => $recentFailures,
'has_failures' => !empty($recentFailures)
])
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取RSS定时任务统计失败: ' . $e->getMessage()
));
}
}
/**
* 获取最近失败的网址(私有方法)
*/
private function getRecentFailedUrls()
{
try {
$db = UrlNav_Plugin::getDbConnection();
// 获取最近24小时内失败的网址
// 移除不存在的字段last_success, error_count
$stmt = $db->prepare("
SELECT
u.id,
u.url,
u.title as site_name,
u.rss_url,
u.last_error,
u.last_refresh,
u.star_rating,
c.name as category_name
FROM urlnav_urls u
LEFT JOIN urlnav_categories c ON u.category_id = c.id
WHERE u.is_active = 1
AND u.rss_url IS NOT NULL
AND u.rss_url != ''
AND u.last_error IS NOT NULL
AND u.last_refresh IS NOT NULL
AND u.last_refresh >= datetime('now', '-1 day')
ORDER BY u.last_refresh DESC
LIMIT 10
");
$stmt->execute();
$urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
return $urls;
} catch (Exception $e) {
error_log('获取失败网址失败: ' . $e->getMessage());
return [];
}
}
/**
* 获取所有失败网址
*/
public function getAllFailedUrls()
{
try {
$db = UrlNav_Plugin::getDbConnection();
// 获取所有历史失败网址(不限时间)
// 移除不存在的字段last_success, error_count
$stmt = $db->prepare("
SELECT
u.id,
u.url,
u.title as site_name,
u.rss_url,
u.last_error,
u.last_refresh,
u.star_rating,
u.is_active,
c.name as category_name
FROM urlnav_urls u
LEFT JOIN urlnav_categories c ON u.category_id = c.id
WHERE u.rss_url IS NOT NULL
AND u.rss_url != ''
AND u.last_error IS NOT NULL
AND u.last_refresh IS NOT NULL
ORDER BY u.last_refresh DESC
");
$stmt->execute();
$urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->response->throwJson(array(
'success' => true,
'data' => $urls
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取失败网址列表失败: ' . $e->getMessage()
));
}
}
/**
* 重试单个失败网址
*/
public function retryRssUrl()
{
try {
$urlId = $this->request->get('url_id');
if (!$urlId) {
throw new Exception('缺少网址ID参数');
}
// 这里调用您的 RSS 刷新逻辑
// 示例UrlNav_Plugin::refreshSingleRssUrl($urlId);
$this->response->throwJson(array(
'success' => true,
'message' => '已开始重试该网址'
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '重试失败: ' . $e->getMessage()
));
}
}
/**
* 忽略失败网址(标记为不活跃)
*/
public function ignoreFailedUrl()
{
try {
$urlId = $this->request->get('url_id');
if (!$urlId) {
throw new Exception('缺少网址ID参数');
}
$db = UrlNav_Plugin::getDbConnection();
$stmt = $db->prepare("UPDATE urlnav_urls SET is_active = 0 WHERE id = ?");
$stmt->execute([$urlId]);
$this->response->throwJson(array(
'success' => true,
'message' => '已忽略该网址'
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '操作失败: ' . $e->getMessage()
));
}
}
/**
* 启用已停用的网址
*/
public function enableFailedUrl()
{
try {
$urlId = $this->request->get('url_id');
if (!$urlId) {
throw new Exception('缺少网址ID参数');
}
$db = UrlNav_Plugin::getDbConnection();
$stmt = $db->prepare("UPDATE urlnav_urls SET is_active = 1 WHERE id = ?");
$stmt->execute([$urlId]);
$this->response->throwJson(array(
'success' => true,
'message' => '已重新启用该网址'
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '启用失败: ' . $e->getMessage()
));
}
}
/**
* 重试所有失败网址
*/
public function retryAllFailedUrls()
{
try {
// 获取所有活跃且有错误的网址
$db = UrlNav_Plugin::getDbConnection();
$stmt = $db->prepare("
SELECT id FROM urlnav_urls
WHERE is_active = 1
AND rss_url IS NOT NULL
AND rss_url != ''
AND last_error IS NOT NULL
");
$stmt->execute();
$urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count = count($urls);
$successCount = 0;
foreach ($urls as $url) {
try {
// 调用刷新单个网址的逻辑
// UrlNav_Plugin::refreshSingleRssUrl($url['id']);
$successCount++;
} catch (Exception $e) {
error_log('重试网址 ' . $url['id'] . ' 失败: ' . $e->getMessage());
}
}
$this->response->throwJson(array(
'success' => true,
'message' => "批量重试完成,成功 {$successCount}/{$count}"
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '批量重试失败: ' . $e->getMessage()
));
}
}
/**
* 获取刷新日志(包含成功和失败的记录)
*/
public function getRefreshLogs()
{
try {
$page = $this->request->get('page', 1);
$pageSize = $this->request->get('pageSize', 20);
$search = $this->request->get('search', '');
$type = $this->request->get('type', ''); // 'success', 'failed', 'all'
$date = $this->request->get('date', '');
$offset = ($page - 1) * $pageSize;
$db = UrlNav_Plugin::getDbConnection();
// 基础查询
$sql = "
SELECT
u.id,
u.title as site_name,
u.url,
u.rss_url,
u.last_refresh,
u.last_error,
u.star_rating,
u.is_active,
c.name as category_name,
CASE
WHEN u.last_error IS NOT NULL AND u.last_error != '' THEN 'failed'
ELSE 'success'
END as status
FROM urlnav_urls u
LEFT JOIN urlnav_categories c ON u.category_id = c.id
WHERE u.rss_url IS NOT NULL
AND u.rss_url != ''
AND u.last_refresh IS NOT NULL
";
$params = [];
// 添加筛选条件
if ($type === 'failed') {
$sql .= " AND u.last_error IS NOT NULL AND u.last_error != ''";
} elseif ($type === 'success') {
$sql .= " AND (u.last_error IS NULL OR u.last_error = '')";
}
if (!empty($search)) {
$sql .= " AND (u.title LIKE ? OR u.url LIKE ? OR u.rss_url LIKE ?)";
$searchParam = "%{$search}%";
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
}
if (!empty($date)) {
$sql .= " AND DATE(u.last_refresh) = ?";
$params[] = $date;
}
// 获取总数
$countSql = "SELECT COUNT(*) as total FROM ($sql) as subquery";
$countStmt = $db->prepare($countSql);
$countStmt->execute($params);
$total = $countStmt->fetch(PDO::FETCH_ASSOC)['total'];
// 获取分页数据
$sql .= " ORDER BY u.last_refresh DESC LIMIT ? OFFSET ?";
$params[] = $pageSize;
$params[] = $offset;
$stmt = $db->prepare($sql);
$stmt->execute($params);
$logs = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 转换时间为北京时间
foreach ($logs as &$log) {
$log['last_refresh_beijing'] = $this->convertToBeijingTimeCorrectly($log['last_refresh']);
$log['status_label'] = $log['status'] === 'failed' ? '失败' : '成功';
}
$this->response->throwJson([
'success' => true,
'data' => [
'logs' => $logs,
'pagination' => [
'total' => (int)$total,
'page' => (int)$page,
'pageSize' => (int)$pageSize,
'totalPages' => ceil($total / $pageSize)
]
]
]);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '获取刷新日志失败: ' . $e->getMessage()
]);
}
}
/**
* 获取最近刷新统计(用于仪表板)
*/
public function getRecentRefreshStats()
{
try {
$days = $this->request->get('days', 7);
$db = UrlNav_Plugin::getDbConnection();
// 1. 获取最近N天的刷新统计
$dailyStatsSql = "
SELECT
DATE(last_refresh) as refresh_date,
COUNT(*) as total_refreshes,
SUM(CASE WHEN last_error IS NOT NULL AND last_error != '' THEN 1 ELSE 0 END) as failed_refreshes,
SUM(CASE WHEN last_error IS NULL OR last_error = '' THEN 1 ELSE 0 END) as success_refreshes
FROM urlnav_urls
WHERE rss_url IS NOT NULL
AND rss_url != ''
AND last_refresh IS NOT NULL
AND last_refresh >= DATE('now', '-' || ? || ' days')
GROUP BY DATE(last_refresh)
ORDER BY refresh_date DESC
";
$stmt = $db->prepare($dailyStatsSql);
$stmt->execute([$days]);
$dailyStats = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 2. 获取成功/失败总数
$totalStatsSql = "
SELECT
COUNT(*) as total_urls,
SUM(CASE WHEN last_error IS NOT NULL AND last_error != '' THEN 1 ELSE 0 END) as total_failed,
SUM(CASE WHEN last_error IS NULL OR last_error = '' THEN 1 ELSE 0 END) as total_success,
SUM(CASE WHEN last_error IS NOT NULL AND last_error != '' AND last_refresh >= datetime('now', '-1 day') THEN 1 ELSE 0 END) as recent_failed
FROM urlnav_urls
WHERE rss_url IS NOT NULL
AND rss_url != ''
AND is_active = 1
";
$totalStmt = $db->prepare($totalStatsSql);
$totalStmt->execute();
$totalStats = $totalStmt->fetch(PDO::FETCH_ASSOC);
// 3. 获取最常见的错误
$commonErrorsSql = "
SELECT
last_error,
COUNT(*) as error_count
FROM urlnav_urls
WHERE last_error IS NOT NULL
AND last_error != ''
AND last_refresh >= datetime('now', '-7 days')
GROUP BY last_error
ORDER BY error_count DESC
LIMIT 10
";
$errorsStmt = $db->prepare($commonErrorsSql);
$errorsStmt->execute();
$commonErrors = $errorsStmt->fetchAll(PDO::FETCH_ASSOC);
$this->response->throwJson([
'success' => true,
'data' => [
'daily_stats' => $dailyStats,
'total_stats' => $totalStats,
'common_errors' => $commonErrors,
'time_range' => $days . '天'
]
]);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '获取刷新统计失败: ' . $e->getMessage()
]);
}
}
/**
* 导出成功网址(修复版)
*/
public function exportSuccessUrls()
{
try {
$db = UrlNav_Plugin::getDbConnection();
// 🔴 修复:查询最近一次刷新成功的网址
// 方法1根据last_error为空判断
$stmt = $db->prepare("
SELECT
u.id,
u.url,
u.title as site_name,
u.rss_url,
u.last_error,
u.last_refresh,
u.success_count,
u.failure_count,
u.star_rating,
u.is_active,
u.is_online,
c.name as category_name
FROM urlnav_urls u
LEFT JOIN urlnav_categories c ON u.category_id = c.id
WHERE u.rss_url IS NOT NULL
AND u.rss_url != ''
AND u.is_active = 1
-- 关键:最近一次刷新没有错误,且成功次数大于失败次数
AND (u.last_error IS NULL OR u.last_error = '')
AND (u.success_count > u.failure_count OR u.success_count > 0)
AND u.last_refresh IS NOT NULL
ORDER BY u.last_refresh DESC
");
$stmt->execute();
$urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 生成CSV内容
$csvContent = "ID,网站名称,网站地址,RSS地址,最后刷新时间(北京时间),成功次数,失败次数,成功率,星级评分,网站状态,分类\n";
foreach ($urls as $url) {
// 使用正确的北京时间转换
$refreshTime = $this->convertToBeijingTimeCorrectly($url['last_refresh']);
// 计算成功率
$total = $url['success_count'] + $url['failure_count'];
$successRate = $total > 0 ? round(($url['success_count'] / $total) * 100, 2) : 0;
$csvContent .= sprintf(
'"%s","%s","%s","%s","%s","%s","%s","%s%%","%s","%s","%s"' . "\n",
$url['id'],
$url['site_name'] ?? '',
$url['url'] ?? '',
$url['rss_url'] ?? '',
$refreshTime,
$url['success_count'] ?? 0,
$url['failure_count'] ?? 0,
$successRate,
$url['star_rating'] ?? 0,
$url['is_online'] ? '在线' : '离线',
$url['category_name'] ?? ''
);
}
$this->response->throwJson(array(
'success' => true,
'csv_data' => $csvContent,
'count' => count($urls)
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '导出成功网址失败: ' . $e->getMessage()
));
}
}
public function exportFailedUrls()
{
try {
$db = UrlNav_Plugin::getDbConnection();
// 🔴 直接复制 exportSuccessUrls() 的SQL结构只改WHERE条件
$stmt = $db->prepare("
SELECT
u.id,
u.url,
u.title as site_name,
u.rss_url,
u.last_error,
u.last_refresh,
u.success_count,
u.failure_count,
u.is_active, // 🔴 注意:失败导出没有 star_rating 字段
u.is_online,
c.name as category_name
FROM urlnav_urls u
LEFT JOIN urlnav_categories c ON u.category_id = c.id
WHERE u.rss_url IS NOT NULL
AND u.rss_url != ''
AND u.is_active = 1
-- 🔴 关键:只改这里,其他完全一样
AND u.last_error IS NOT NULL
AND u.last_error != ''
ORDER BY u.last_refresh DESC
");
$stmt->execute();
$urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 🔴 直接复制 exportSuccessUrls() 的CSV生成逻辑
$csvContent = "ID,网站名称,网站地址,RSS地址,最后刷新时间(北京时间),错误信息,成功次数,失败次数,成功率,网站状态,分类\n";
foreach ($urls as $url) {
// 🔴 使用相同的时间转换方法
$refreshTime = '';
if (!empty($url['last_refresh']) && $url['last_refresh'] != '0000-00-00 00:00:00') {
$date = new DateTime($url['last_refresh']);
$date->setTimezone(new DateTimeZone('Asia/Shanghai'));
$refreshTime = $date->format('Y-m-d H:i:s');
} else {
$refreshTime = '从未刷新';
}
// 计算成功率
$total = $url['success_count'] + $url['failure_count'];
$successRate = $total > 0 ? round(($url['success_count'] / $total) * 100, 2) : 0;
$csvContent .= sprintf(
'"%s","%s","%s","%s","%s","%s","%s","%s","%s%%","%s","%s"' . "\n",
$url['id'],
$url['site_name'] ?? '',
$url['url'] ?? '',
$url['rss_url'] ?? '',
$refreshTime,
str_replace('"', '""', $url['last_error'] ?? ''),
$url['success_count'] ?? 0,
$url['failure_count'] ?? 0,
$successRate,
$url['is_online'] ? '在线' : '离线',
$url['category_name'] ?? ''
);
}
$this->response->throwJson(array(
'success' => true,
'csv_data' => $csvContent,
'count' => count($urls)
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '导出失败: ' . $e->getMessage()
));
}
}
/**
* 正确的北京时间转换方法
*/
private function convertToBeijingTimeCorrectly($datetime)
{
if (empty($datetime) || $datetime == '0000-00-00 00:00:00') {
return '';
}
try {
// 方法1明确指定输入时区如果知道数据库存储的时区
// 假设数据库存储的是UTC时间
$utc = new DateTimeZone('UTC');
$beijing = new DateTimeZone('Asia/Shanghai');
// 创建DateTime对象明确指定输入时区为UTC
$date = DateTime::createFromFormat('Y-m-d H:i:s', $datetime, $utc);
if ($date === false) {
// 如果格式不匹配,尝试其他方法
$date = new DateTime($datetime, $utc);
}
// 转换为北京时间
$date->setTimezone($beijing);
return $date->format('Y-m-d H:i:s');
} catch (Exception $e) {
// 方法2使用你的Rss.php中的方法
return $this->convertUsingRssMethod($datetime);
}
}
/**
* 复制Rss.php中的convertToBeijingTime方法
*/
private function convertUsingRssMethod($datetime)
{
if (empty($datetime) || $datetime == '0000-00-00 00:00:00' || $datetime == '0000-00-00 00:00') {
return '';
}
try {
// 创建DateTime对象
$date = new DateTime($datetime);
// 如果已经是北京时间,直接返回
if ($date->getTimezone()->getName() == 'Asia/Shanghai') {
return $date->format('Y-m-d H:i:s');
}
// 转换为北京时间
$date->setTimezone(new DateTimeZone('Asia/Shanghai'));
return $date->format('Y-m-d H:i:s');
} catch (Exception $e) {
// 如果解析失败,尝试简单的时间转换
$timestamp = strtotime($datetime);
if ($timestamp !== false) {
// 服务器时间已经是北京时间,直接返回
return date('Y-m-d H:i:s', $timestamp);
}
return $datetime;
}
}
/**
* 获取状态检查定时任务统计
*/
public function getStatusCronStats()
{
try {
$stats = UrlNav_Plugin::getStatusCronStats();
$this->response->throwJson(array(
'success' => true,
'data' => $stats
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取状态检查定时任务统计失败: ' . $e->getMessage()
));
}
}
/**
* 检查网站状态 - 修复版
*/
public function checkStatus()
{
try {
// 检查是否是批量检查
$urlIds = $this->request->get('url_ids');
$batchInfo = $this->request->get('batch_info');
// 解析URL IDs
$idArray = null;
if ($urlIds && $urlIds !== '') {
// 处理不同的ID格式
if (is_array($urlIds)) {
$idArray = $urlIds;
} else if (strpos($urlIds, ',') !== false) {
// 逗号分隔的字符串
$idArray = array_map('trim', explode(',', $urlIds));
$idArray = array_filter($idArray, function($id) {
return is_numeric($id) && $id > 0;
});
} else if (is_numeric($urlIds)) {
// 单个ID
$idArray = [$urlIds];
}
}
// 解析批次信息
$batchData = null;
if ($batchInfo && is_string($batchInfo)) {
$batchData = json_decode($batchInfo, true);
} else if (is_array($batchInfo)) {
$batchData = $batchInfo;
}
// 如果是选中的网址,需要特殊处理批次信息
if (!empty($idArray) && $batchData) {
// 重新计算批次信息,因为选中的网址总数可能和全部网址不同
$batchSize = $batchData['size'] ?? 10;
$totalSelected = count($idArray);
$totalBatches = ceil($totalSelected / $batchSize);
$batchNumber = $batchData['batch'] ?? 1;
// 确保批次号不超过总批次
if ($batchNumber > $totalBatches) {
return $this->response->throwJson([
'success' => true,
'message' => '所有选中的网址已检查完成',
'total' => 0,
'success_count' => 0,
'failed_count' => 0,
'has_more' => false
]);
}
// 获取当前批次要检查的ID
$offset = ($batchNumber - 1) * $batchSize;
$batchIds = array_slice($idArray, $offset, $batchSize);
// 调用检查函数
$result = UrlNav_Plugin::manualCheckStatus($batchIds, true, $batchInfo);
// 更新批次信息
$result['batch_number'] = $batchNumber;
$result['total_batches'] = $totalBatches;
$result['batch_info'] = json_encode([
'batch' => $batchNumber,
'total' => $totalBatches,
'size' => $batchSize,
'selected_ids' => implode(',', $idArray) // 记录所有选中的ID
]);
$this->response->throwJson($result);
return;
}
// 如果没有选中网址,检查全部(已有的逻辑)
$result = UrlNav_Plugin::manualCheckStatus(null, true, $batchInfo);
$this->response->throwJson($result);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '检查失败: ' . $e->getMessage()
]);
}
}
// 在 Action.php 中修复 checkSingleStatus 方法
public function checkSingleStatus()
{
try {
$urlId = $this->request->get('id');
if (empty($urlId)) {
$urlId = $this->request->get('url_id');
}
if (empty($urlId)) {
throw new Exception('ID不能为空');
}
$result = UrlNav_Plugin::manualCheckStatus($urlId);
// 修复:检查返回的数据结构
if (isset($result['success']) && $result['success'] === true) {
if (isset($result['results']) && is_array($result['results'])) {
// 如果是批量检查的结果
foreach ($result['results'] as $urlId => $checkResult) {
$this->response->throwJson([
'success' => true,
'message' => '检查完成',
'data' => $checkResult
]);
return;
}
} else {
// 如果是单次检查的结果
$this->response->throwJson([
'success' => true,
'message' => '检查完成',
'data' => $result['data'] ?? $result
]);
return;
}
} else {
// 检查失败
$this->response->throwJson([
'success' => false,
'message' => $result['message'] ?? '检查失败'
]);
}
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '检查失败: ' . $e->getMessage()
]);
}
}
/**
* 获取状态统计
*/
public function getStatusStats()
{
try {
$stats = UrlNav_Plugin::getStatusStats();
$this->response->throwJson([
'success' => true,
'data' => $stats
]);
} catch (Exception $e) {
$this->response->throwJson([
'success' => false,
'message' => '获取统计失败: ' . $e->getMessage()
]);
}
}
/**
* 添加分类 - 模仿Collection的add方法
*/
public function addCategory()
{
try {
$name = $this->request->get('name');
$description = $this->request->get('description');
$sort_order = $this->request->get('sort_order', 0);
if (empty($name)) {
throw new Exception('分类名称不能为空');
}
// 检查名称是否已存在 - 模仿Collection插件
$stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_categories WHERE name = ? AND is_active = 1");
$stmt->execute(array($name));
$count = $stmt->fetchColumn();
if ($count > 0) {
throw new Exception('分类名称已存在');
}
$stmt = $this->db->prepare("INSERT INTO urlnav_categories (name, description, sort_order) VALUES (?, ?, ?)");
$stmt->execute(array($name, $description, $sort_order));
$id = $this->db->lastInsertId();
$this->response->throwJson(array(
'success' => true,
'message' => '分类添加成功',
'data' => array('id' => $id)
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '添加失败: ' . $e->getMessage()
));
}
}
/**
* 更新分类 - 模仿Collection的update方法
*/
public function updateCategory()
{
try {
$id = $this->request->get('id');
$name = $this->request->get('name');
$description = $this->request->get('description');
$sort_order = $this->request->get('sort_order', 0);
if (empty($id) || empty($name)) {
throw new Exception('ID和分类名称不能为空');
}
// 检查名称是否已存在(排除自身)- 模仿Collection插件
$stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_categories WHERE name = ? AND id != ? AND is_active = 1");
$stmt->execute(array($name, $id));
$count = $stmt->fetchColumn();
if ($count > 0) {
throw new Exception('分类名称已存在');
}
$stmt = $this->db->prepare("UPDATE urlnav_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?");
$result = $stmt->execute(array($name, $description, $sort_order, $id));
if ($result) {
$this->response->throwJson(array(
'success' => true,
'message' => '分类更新成功'
));
} else {
throw new Exception('分类不存在或更新失败');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '更新失败: ' . $e->getMessage()
));
}
}
/**
* 删除分类 - 模仿Collection的delete方法
*/
public function deleteCategory()
{
try {
$id = $this->request->get('id');
if (empty($id)) {
throw new Exception('ID不能为空');
}
// 检查分类下是否有网址
$checkStmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE category_id = ? AND is_active = 1");
$checkStmt->execute(array($id));
$urlCount = $checkStmt->fetchColumn();
if ($urlCount > 0) {
throw new Exception('该分类下还有' . $urlCount . '个网址,无法删除');
}
$stmt = $this->db->prepare("UPDATE urlnav_categories SET is_active = 0 WHERE id = ?");
$result = $stmt->execute(array($id));
if ($result) {
$this->response->throwJson(array(
'success' => true,
'message' => '分类删除成功'
));
} else {
throw new Exception('分类不存在或删除失败');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '删除失败: ' . $e->getMessage()
));
}
}
/**
* 批量删除分类 - 模仿Collection的batchDelete方法
*/
public function batchDeleteCategories()
{
try {
// Collection插件处理数组的方式
$ids = $this->request->filter('int')->getArray('category');
if (empty($ids)) {
$rawIds = $this->request->get('category');
if (!empty($rawIds)) {
$ids = is_array($rawIds) ? $rawIds : array($rawIds);
$ids = array_map('intval', $ids);
}
}
if (empty($ids) || !is_array($ids)) {
throw new Exception('请选择要删除的分类');
}
$ids = array_filter($ids, function($id) {
return is_numeric($id) && $id > 0;
});
if (empty($ids)) {
throw new Exception('无效的分类ID');
}
// 检查是否有网址关联到这些分类
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$checkStmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE category_id IN ($placeholders) AND is_active = 1");
$checkStmt->execute($ids);
$urlCount = $checkStmt->fetchColumn();
if ($urlCount > 0) {
throw new Exception('选中的分类下还有' . $urlCount . '个网址,无法删除');
}
$stmt = $this->db->prepare("UPDATE urlnav_categories SET is_active = 0 WHERE id IN ($placeholders)");
$result = $stmt->execute($ids);
if ($result) {
$this->response->throwJson(array(
'success' => true,
'message' => '分类批量删除成功'
));
} else {
throw new Exception('删除失败');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '批量删除失败: ' . $e->getMessage()
));
}
}
/**
* 获取单个分类 - 模仿Collection的get方法
*/
public function getCategory()
{
try {
$id = $this->request->get('id');
if (empty($id)) {
throw new Exception('ID不能为空');
}
$stmt = $this->db->prepare("SELECT * FROM urlnav_categories WHERE id = ?");
$stmt->execute(array($id));
$category = $stmt->fetch(PDO::FETCH_ASSOC);
if ($category) {
$this->response->throwJson(array(
'success' => true,
'data' => $category
));
} else {
throw new Exception('分类不存在');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '查询失败: ' . $e->getMessage()
));
}
}
/**
* 获取所有分类 - 模仿Collection的getAll方法
*/
public function getAllCategories()
{
try {
$stmt = $this->db->query("SELECT * FROM urlnav_categories WHERE is_active = 1 ORDER BY sort_order, created_at DESC");
$categories = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->response->throwJson(array(
'success' => true,
'data' => $categories
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '查询失败: ' . $e->getMessage()
));
}
}
/**
* 添加网址 - 模仿Collection的add方法
*/
public function addUrl()
{
try {
$title = $this->request->get('title');
$url = $this->request->get('url');
$description = $this->request->get('description');
$rss_url = $this->request->get('rss_url', '');
$category_id = $this->request->get('category_id');
$sort_order = $this->request->get('sort_order', 0);
if (empty($title)) {
throw new Exception('网站标题不能为空');
}
if (empty($url)) {
throw new Exception('网站地址不能为空');
}
// 验证URL格式
if (!UrlNav_Plugin::validateUrl($url)) {
throw new Exception('网站地址格式无效请使用http://或https://开头的完整地址');
}
// 如果提供了RSS地址验证格式
if (!empty($rss_url) && !UrlNav_Plugin::validateUrl($rss_url)) {
throw new Exception('RSS地址格式无效请使用http://或https://开头的完整地址');
}
// 检查标题是否已存在
$stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE title = ? AND is_active = 1");
$stmt->execute(array($title));
$count = $stmt->fetchColumn();
if ($count > 0) {
throw new Exception('网站标题已存在');
}
// 获取星级评分
$starRating = $this->request->get('star_rating', 0);
$starRating = intval($starRating);
// 限制在0-3之间
$starRating = max(0, min(3, $starRating));
// 检查URL是否已存在
$stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE url = ? AND is_active = 1");
$stmt->execute(array($url));
$count = $stmt->fetchColumn();
if ($count > 0) {
throw new Exception('网站地址已存在');
}
$stmt = $this->db->prepare("INSERT INTO urlnav_urls (title, url, description, rss_url, star_rating, category_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute(array($title, $url, $description, $rss_url, $starRating, $category_id, $sort_order));
$id = $this->db->lastInsertId();
$this->response->throwJson(array(
'success' => true,
'message' => '网址添加成功',
'data' => array('id' => $id)
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '添加失败: ' . $e->getMessage()
));
}
}
/**
* 更新网址 - 模仿Collection的update方法
*/
/**
* 更新网址 - 模仿Collection的update方法
*/
public function updateUrl()
{
try {
$id = $this->request->get('id');
$title = $this->request->get('title');
$url = $this->request->get('url');
$description = $this->request->get('description');
$rss_url = $this->request->get('rss_url', '');
$category_id = $this->request->get('category_id');
$sort_order = $this->request->get('sort_order', 0);
// 新增:获取星级评分
$star_rating = $this->request->get('star_rating', 0);
$star_rating = intval($star_rating);
// 限制在0-3之间
$star_rating = max(0, min(3, $star_rating));
if (empty($id) || empty($title) || empty($url)) {
throw new Exception('ID、标题和网址不能为空');
}
// 验证URL格式
if (!UrlNav_Plugin::validateUrl($url)) {
throw new Exception('网站地址格式无效请使用http://或https://开头的完整地址');
}
// 如果提供了RSS地址验证格式
if (!empty($rss_url) && !UrlNav_Plugin::validateUrl($rss_url)) {
throw new Exception('RSS地址格式无效请使用http://或https://开头的完整地址');
}
// 检查标题是否已存在(排除自身)
$stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE title = ? AND id != ? AND is_active = 1");
$stmt->execute(array($title, $id));
$count = $stmt->fetchColumn();
if ($count > 0) {
throw new Exception('网站标题已存在');
}
// 检查URL是否已存在排除自身
$stmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE url = ? AND id != ? AND is_active = 1");
$stmt->execute(array($url, $id));
$count = $stmt->fetchColumn();
if ($count > 0) {
throw new Exception('网站地址已存在');
}
// 修改这里:添加 star_rating 字段
$stmt = $this->db->prepare("UPDATE urlnav_urls SET title = ?, url = ?, description = ?, rss_url = ?, category_id = ?, sort_order = ?, star_rating = ? WHERE id = ?");
$result = $stmt->execute(array($title, $url, $description, $rss_url, $category_id, $sort_order, $star_rating, $id));
if ($result) {
$this->response->throwJson(array(
'success' => true,
'message' => '网址更新成功'
));
} else {
throw new Exception('网址不存在或更新失败');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '更新失败: ' . $e->getMessage()
));
}
}
/**
* 删除网址 - 模仿Collection的delete方法
*/
public function deleteUrl()
{
try {
$id = $this->request->get('id');
if (empty($id)) {
throw new Exception('ID不能为空');
}
$stmt = $this->db->prepare("UPDATE urlnav_urls SET is_active = 0 WHERE id = ?");
$result = $stmt->execute(array($id));
if ($result) {
$this->response->throwJson(array(
'success' => true,
'message' => '网址删除成功'
));
} else {
throw new Exception('网址不存在或删除失败');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '删除失败: ' . $e->getMessage()
));
}
}
/**
* 批量删除网址 - 模仿Collection的batchDelete方法
*/
public function batchDeleteUrls()
{
try {
// Collection插件处理数组的方式
$ids = $this->request->filter('int')->getArray('url');
if (empty($ids)) {
$rawIds = $this->request->get('url');
if (!empty($rawIds)) {
$ids = is_array($rawIds) ? $rawIds : array($rawIds);
$ids = array_map('intval', $ids);
}
}
if (empty($ids) || !is_array($ids)) {
throw new Exception('请选择要删除的网址');
}
$ids = array_filter($ids, function($id) {
return is_numeric($id) && $id > 0;
});
if (empty($ids)) {
throw new Exception('无效的网址ID');
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $this->db->prepare("UPDATE urlnav_urls SET is_active = 0 WHERE id IN ($placeholders)");
$result = $stmt->execute($ids);
if ($result) {
$this->response->throwJson(array(
'success' => true,
'message' => '网址批量删除成功'
));
} else {
throw new Exception('删除失败');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '批量删除失败: ' . $e->getMessage()
));
}
}
/**
* 获取单个网址 - 模仿Collection的get方法
*/
public function getUrl()
{
try {
$id = $this->request->get('id');
if (empty($id)) {
throw new Exception('ID不能为空');
}
$stmt = $this->db->prepare("SELECT * FROM urlnav_urls WHERE id = ?");
$stmt->execute(array($id));
$url = $stmt->fetch(PDO::FETCH_ASSOC);
if ($url) {
$this->response->throwJson(array(
'success' => true,
'data' => $url
));
} else {
throw new Exception('网址不存在');
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '查询失败: ' . $e->getMessage()
));
}
}
/**
* 获取所有网址 - 模仿Collection的getAll方法
*/
public function getAllUrls()
{
try {
$stmt = $this->db->query("SELECT * FROM urlnav_urls WHERE is_active = 1 ORDER BY sort_order, created_at DESC");
$urls = $stmt->fetchAll(PDO::FETCH_ASSOC);
$this->response->throwJson(array(
'success' => true,
'data' => $urls
));
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '查询失败: ' . $e->getMessage()
));
}
}
/**
* 导出OPML格式数据
*/
public function exportOpml()
{
try {
// 获取所有分类和网址
$categories = $this->db->query("SELECT * FROM urlnav_categories WHERE is_active = 1 ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC);
$urls = $this->db->query("SELECT * FROM urlnav_urls WHERE is_active = 1 AND rss_url != '' AND rss_url IS NOT NULL AND TRIM(rss_url) != '' ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC);
// 按分类组织网址
$urlsByCategory = [];
foreach ($urls as $url) {
$categoryId = $url['category_id'] ? $url['category_id'] : 'uncategorized';
if (!isset($urlsByCategory[$categoryId])) {
$urlsByCategory[$categoryId] = [];
}
$urlsByCategory[$categoryId][] = $url;
}
// 创建OPML XML
$xml = new DOMDocument('1.0', 'UTF-8');
$xml->formatOutput = true;
$opml = $xml->createElement('opml');
$opml->setAttribute('version', '2.0');
$opml->setAttribute('xmlns:frss', 'https://freshrss.org/opml');
$xml->appendChild($opml);
// head部分
$head = $xml->createElement('head');
$opml->appendChild($head);
$title = $xml->createElement('title', 'UrlNav RSS订阅');
$head->appendChild($title);
$dateCreated = $xml->createElement('dateCreated', date('r'));
$head->appendChild($dateCreated);
// body部分
$body = $xml->createElement('body');
$opml->appendChild($body);
// 处理有分类的网址
foreach ($categories as $category) {
$categoryId = $category['id'];
if (isset($urlsByCategory[$categoryId]) && !empty($urlsByCategory[$categoryId])) {
$outline = $xml->createElement('outline');
$outline->setAttribute('text', $category['name']);
foreach ($urlsByCategory[$categoryId] as $url) {
$feedOutline = $xml->createElement('outline');
$feedOutline->setAttribute('text', $url['title']);
$feedOutline->setAttribute('type', 'rss');
$feedOutline->setAttribute('xmlUrl', $url['rss_url']);
$feedOutline->setAttribute('htmlUrl', $url['url']);
if (!empty($url['description'])) {
$feedOutline->setAttribute('description', $url['description']);
}
$outline->appendChild($feedOutline);
}
$body->appendChild($outline);
}
}
// 处理未分类的网址
if (isset($urlsByCategory['uncategorized']) && !empty($urlsByCategory['uncategorized'])) {
$outline = $xml->createElement('outline');
$outline->setAttribute('text', '未分类');
foreach ($urlsByCategory['uncategorized'] as $url) {
$feedOutline = $xml->createElement('outline');
$feedOutline->setAttribute('text', $url['title']);
$feedOutline->setAttribute('type', 'rss');
$feedOutline->setAttribute('xmlUrl', $url['rss_url']);
$feedOutline->setAttribute('htmlUrl', $url['url']);
if (!empty($url['description'])) {
$feedOutline->setAttribute('description', $url['description']);
}
$outline->appendChild($feedOutline);
}
$body->appendChild($outline);
}
// 设置响应头
$filename = 'urlnav_feeds_' . date('Y-m-d') . '.opml.xml';
$this->response->setHeader('Content-Type', 'application/xml');
$this->response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
$this->response->setHeader('Cache-Control', 'no-cache, must-revalidate');
$this->response->setHeader('Pragma', 'no-cache');
$this->response->setHeader('Expires', '0');
echo $xml->saveXML();
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '导出失败: ' . $e->getMessage()
));
}
}
/**
* 导入OPML格式数据 - 修复版
*/
public function importOpml()
{
try {
// 检查是否有文件上传
if (!isset($_FILES['opml_file']) || $_FILES['opml_file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception('请选择要上传的OPML文件错误代码: ' . ($_FILES['opml_file']['error'] ?? '未知'));
}
$file = $_FILES['opml_file'];
// 检查文件大小限制为2MB
if ($file['size'] > 2 * 1024 * 1024) {
throw new Exception('文件大小超过2MB限制');
}
if ($file['size'] == 0) {
throw new Exception('文件为空');
}
// 读取文件内容
$content = file_get_contents($file['tmp_name']);
if (empty($content)) {
throw new Exception('文件内容为空');
}
// 检查文件编码并转换为UTF-8
$encoding = mb_detect_encoding($content, ['UTF-8', 'GBK', 'GB2312', 'BIG5', 'ASCII'], true);
if ($encoding && $encoding != 'UTF-8') {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
// 尝试移除可能的BOM头
if (substr($content, 0, 3) == "\xEF\xBB\xBF") {
$content = substr($content, 3);
}
// 解析XML
libxml_use_internal_errors(true);
$xml = simplexml_load_string($content);
if ($xml === false) {
$errors = libxml_get_errors();
$errorMsg = 'XML解析失败: ';
foreach ($errors as $error) {
$errorMsg .= '行 ' . $error->line . ', 列 ' . $error->column . ': ' . $error->message . '; ';
}
libxml_clear_errors();
throw new Exception($errorMsg);
}
// 检查是否是有效的OPML格式
if (!isset($xml->head) || !isset($xml->body)) {
throw new Exception('无效的OPML格式缺少head或body元素');
}
$importResults = [
'total' => 0,
'success' => 0,
'failed' => 0,
'categories_created' => 0,
'urls_added' => 0,
'urls_skipped' => 0,
'details' => []
];
// 开始事务
$this->db->beginTransaction();
try {
// 获取现有分类映射
$existingCategories = [];
$stmt = $this->db->query("SELECT id, name FROM urlnav_categories WHERE is_active = 1");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$existingCategories[$row['name']] = $row['id'];
}
// 获取现有网址映射
$existingUrls = [];
$existingRssUrls = [];
$stmt = $this->db->query("SELECT url, rss_url, title FROM urlnav_urls WHERE is_active = 1");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
if (!empty($row['url'])) {
$existingUrls[$row['url']] = true;
}
if (!empty($row['rss_url'])) {
$existingRssUrls[$row['rss_url']] = true;
}
}
// 处理OPML结构 - 简化版本
$processedCount = 0;
// 遍历body下的所有outline元素
foreach ($xml->body->children() as $outline) {
$processedCount++;
// 检查是否有子元素(可能是分类)
$hasChildren = $outline->children()->count() > 0;
if ($hasChildren) {
// 可能是分类
$categoryName = (string)$outline['text'];
if (empty($categoryName)) {
$categoryName = '未命名分类_' . $processedCount;
}
// 处理分类
if (!isset($existingCategories[$categoryName])) {
// 创建新分类
$stmt = $this->db->prepare("INSERT INTO urlnav_categories (name, description, sort_order) VALUES (?, ?, ?)");
$stmt->execute([$categoryName, '从OPML导入', 999]);
$categoryId = $this->db->lastInsertId();
$existingCategories[$categoryName] = $categoryId;
$importResults['categories_created']++;
$importResults['details'][] = [
'type' => 'category',
'name' => $categoryName,
'status' => 'created',
'message' => '分类创建成功'
];
} else {
$categoryId = $existingCategories[$categoryName];
}
// 处理该分类下的feed
foreach ($outline->children() as $feed) {
$importResults['total']++;
$title = (string)$feed['text'];
$rssUrl = (string)$feed['xmlUrl'];
$htmlUrl = (string)$feed['htmlUrl'];
$description = isset($feed['description']) ? (string)$feed['description'] : '';
// 如果htmlUrl为空尝试使用link属性
if (empty($htmlUrl) && isset($feed['link'])) {
$htmlUrl = (string)$feed['link'];
}
// 如果htmlUrl仍然为空使用RSS地址作为网站地址
if (empty($htmlUrl) && !empty($rssUrl)) {
$htmlUrl = $rssUrl;
}
// 验证数据
if (empty($title) && empty($rssUrl)) {
$importResults['failed']++;
$importResults['details'][] = [
'type' => 'url',
'title' => '未命名',
'status' => 'failed',
'message' => '标题和RSS地址都为空'
];
continue;
}
// 如果没有标题使用RSS地址的一部分作为标题
if (empty($title)) {
$parsedUrl = parse_url($rssUrl);
$title = $parsedUrl['host'] ?? '未命名网站';
}
// 验证URL格式
if (!empty($rssUrl) && !UrlNav_Plugin::validateUrl($rssUrl)) {
// 尝试修复URL
if (!preg_match('/^https?:\/\//', $rssUrl)) {
$rssUrl = 'https://' . $rssUrl;
if (!UrlNav_Plugin::validateUrl($rssUrl)) {
$importResults['failed']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'failed',
'message' => 'RSS地址格式无效: ' . $rssUrl
];
continue;
}
}
}
if (!empty($htmlUrl) && !UrlNav_Plugin::validateUrl($htmlUrl)) {
// 尝试修复URL
if (!preg_match('/^https?:\/\//', $htmlUrl)) {
$htmlUrl = 'https://' . $htmlUrl;
if (!UrlNav_Plugin::validateUrl($htmlUrl)) {
$importResults['failed']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'failed',
'message' => '网站地址格式无效: ' . $htmlUrl
];
continue;
}
}
}
// 检查网址是否已存在
$skipDuplicate = false;
if (!empty($htmlUrl) && isset($existingUrls[$htmlUrl])) {
$skipDuplicate = true;
}
if (!empty($rssUrl) && isset($existingRssUrls[$rssUrl])) {
$skipDuplicate = true;
}
// 检查标题是否已存在
if (!$skipDuplicate) {
$checkStmt = $this->db->prepare("SELECT COUNT(*) FROM urlnav_urls WHERE title = ? AND is_active = 1");
$checkStmt->execute([$title]);
if ($checkStmt->fetchColumn() > 0) {
$skipDuplicate = true;
}
}
if ($skipDuplicate) {
$importResults['urls_skipped']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'skipped',
'message' => '网址已存在'
];
continue;
}
try {
// 插入新网址
$stmt = $this->db->prepare("
INSERT INTO urlnav_urls
(title, url, description, rss_url, category_id, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
");
$result = $stmt->execute([
$title,
$htmlUrl,
$description,
$rssUrl,
$categoryId,
999
]);
if ($result) {
if (!empty($htmlUrl)) {
$existingUrls[$htmlUrl] = true;
}
if (!empty($rssUrl)) {
$existingRssUrls[$rssUrl] = true;
}
$importResults['success']++;
$importResults['urls_added']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'added',
'message' => '网址添加成功'
];
} else {
$importResults['failed']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'failed',
'message' => '数据库插入失败'
];
}
} catch (Exception $e) {
$importResults['failed']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'failed',
'message' => '数据库错误: ' . $e->getMessage()
];
}
}
} else {
// 可能是独立的feed
$importResults['total']++;
$title = (string)$outline['text'];
$rssUrl = (string)$outline['xmlUrl'];
$htmlUrl = (string)$outline['htmlUrl'];
$description = isset($outline['description']) ? (string)$outline['description'] : '';
// 验证数据
if (empty($title) && empty($rssUrl)) {
continue; // 跳过无效条目
}
// 如果没有标题使用RSS地址的一部分作为标题
if (empty($title)) {
$parsedUrl = parse_url($rssUrl);
$title = $parsedUrl['host'] ?? '未命名网站';
}
// 使用默认分类
$defaultCategoryName = 'OPML导入';
if (!isset($existingCategories[$defaultCategoryName])) {
$stmt = $this->db->prepare("INSERT INTO urlnav_categories (name, description, sort_order) VALUES (?, ?, ?)");
$stmt->execute([$defaultCategoryName, '从OPML导入的网址', 999]);
$categoryId = $this->db->lastInsertId();
$existingCategories[$defaultCategoryName] = $categoryId;
$importResults['categories_created']++;
} else {
$categoryId = $existingCategories[$defaultCategoryName];
}
// 检查是否已存在
$skipDuplicate = false;
if (!empty($htmlUrl) && isset($existingUrls[$htmlUrl])) {
$skipDuplicate = true;
}
if (!empty($rssUrl) && isset($existingRssUrls[$rssUrl])) {
$skipDuplicate = true;
}
if ($skipDuplicate) {
$importResults['urls_skipped']++;
continue;
}
// 插入新网址
$stmt = $this->db->prepare("
INSERT INTO urlnav_urls
(title, url, description, rss_url, category_id, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
");
$result = $stmt->execute([
$title,
$htmlUrl,
$description,
$rssUrl,
$categoryId,
999
]);
if ($result) {
if (!empty($htmlUrl)) {
$existingUrls[$htmlUrl] = true;
}
if (!empty($rssUrl)) {
$existingRssUrls[$rssUrl] = true;
}
$importResults['success']++;
$importResults['urls_added']++;
$importResults['details'][] = [
'type' => 'url',
'title' => $title,
'status' => 'added',
'message' => '网址添加成功'
];
}
}
}
// 提交事务
$this->db->commit();
// 检查是否有导入数据
if ($importResults['total'] == 0) {
throw new Exception('OPML文件中没有找到有效的RSS订阅数据');
}
$message = sprintf(
'导入完成:共处理%d个项目成功%d个失败%d个。创建%d个分类添加%d个网址跳过%d个重复项。',
$importResults['total'],
$importResults['success'],
$importResults['failed'],
$importResults['categories_created'],
$importResults['urls_added'],
$importResults['urls_skipped']
);
$this->response->throwJson(array(
'success' => true,
'message' => $message,
'data' => $importResults
));
} catch (Exception $e) {
$this->db->rollBack();
throw $e;
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '导入失败: ' . $e->getMessage()
));
}
}
/**
* 获取网站信息标题、描述、RSS地址
*/
public function fetchRss()
{
try {
$url = $this->request->get('url');
if (empty($url)) {
throw new Exception('网址不能为空');
}
// 验证URL格式
if (!UrlNav_Plugin::validateUrl($url)) {
throw new Exception('网站地址格式无效');
}
// 获取网页内容
$html = @file_get_contents($url, false, stream_context_create(array(
'http' => array(
'timeout' => 10,
'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36\r\n" .
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8\r\n" .
"Accept-Language: zh-CN,zh;q=0.8,en;q=0.6\r\n"
)
)));
if ($html === false) {
throw new Exception('无法访问该网站');
}
// 检测编码并转换为UTF-8
$encoding = mb_detect_encoding($html, array('UTF-8', 'GBK', 'GB2312', 'BIG5', 'ASCII'), true);
if ($encoding && $encoding != 'UTF-8') {
$html = mb_convert_encoding($html, 'UTF-8', $encoding);
}
// 移除BOM头
if (substr($html, 0, 3) == "\xEF\xBB\xBF") {
$html = substr($html, 3);
}
// 1. 获取网站标题
$siteTitle = '';
if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $matches)) {
$siteTitle = trim(strip_tags($matches[1]));
$siteTitle = preg_replace('/\s+/', ' ', $siteTitle);
$siteTitle = html_entity_decode($siteTitle, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// 2. 获取网站描述
$siteDescription = '';
// 尝试多种meta标签格式
$metaPatterns = array(
'/<meta[^>]*name=["\']description["\'][^>]*content=["\']([^"\']*)["\'][^>]*>/i',
'/<meta[^>]*content=["\']([^"\']*)["\'][^>]*name=["\']description["\'][^>]*>/i',
'/<meta[^>]*property=["\']og:description["\'][^>]*content=["\']([^"\']*)["\'][^>]*>/i',
'/<meta[^>]*content=["\']([^"\']*)["\'][^>]*property=["\']og:description["\'][^>]*>/i'
);
foreach ($metaPatterns as $pattern) {
if (preg_match($pattern, $html, $matches)) {
$siteDescription = trim($matches[1]);
$siteDescription = html_entity_decode($siteDescription, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (!empty($siteDescription)) {
break;
}
}
}
// 3. 查找RSS链接
$rssUrls = array();
// 查找 <link> 标签中的RSS
preg_match_all('/<link[^>]+rel=["\'](alternate|rss|rss feed|feed)["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $html, $matches);
if (!empty($matches[2])) {
foreach ($matches[2] as $rssUrl) {
$rssUrls[] = $rssUrl;
}
}
// 查找 <a> 标签中的RSS
preg_match_all('/<a[^>]+href=["\']([^"\']+\.(xml|rss|atom|rdf))["\'][^>]*>/i', $html, $matches);
if (!empty($matches[1])) {
foreach ($matches[1] as $rssUrl) {
$rssUrls[] = $rssUrl;
}
}
// 查找 <link> 标签中的 feed
preg_match_all('/<link[^>]+type=["\'](application\/rss\+xml|application\/atom\+xml|application\/rdf\+xml)["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $html, $matches);
if (!empty($matches[2])) {
foreach ($matches[2] as $rssUrl) {
$rssUrls[] = $rssUrl;
}
}
// 去重并转换相对路径为绝对路径
$rssUrls = array_unique($rssUrls);
$absoluteRssUrls = array();
$baseUrl = parse_url($url, PHP_URL_SCHEME) . '://' . parse_url($url, PHP_URL_HOST);
foreach ($rssUrls as $rssUrl) {
if (strpos($rssUrl, 'http') === 0) {
$absoluteRssUrls[] = $rssUrl;
} else if (strpos($rssUrl, '/') === 0) {
$absoluteRssUrls[] = $baseUrl . $rssUrl;
} else {
$absoluteRssUrls[] = $baseUrl . '/' . $rssUrl;
}
}
// 准备返回结果
$result = array(
'success' => true,
'siteInfo' => array(
'title' => $siteTitle,
'description' => $siteDescription,
'url' => $url
)
);
if (empty($absoluteRssUrls)) {
$result['hasRss'] = false;
$result['message'] = '成功获取网站信息但未找到RSS地址';
} else {
$result['hasRss'] = true;
$result['rssUrls'] = $absoluteRssUrls;
$result['message'] = '成功获取网站信息,找到 ' . count($absoluteRssUrls) . ' 个RSS地址';
}
$this->response->throwJson($result);
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取网站信息失败: ' . $e->getMessage()
));
}
}
/**
* 获取所有RSS信息并保存到缓存
*/
public function getRssFeeds()
{
try {
$result = UrlNav_Plugin::manualRefreshRss();
if ($result['success']) {
$this->response->throwJson(array(
'success' => true,
'total' => $result['totalFeeds'] ?? 0,
'successCount' => $result['successCount'] ?? 0,
'newArticles' => $result['newArticles'] ?? 0,
'urlCount' => $result['urlCount'] ?? 0,
'message' => $result['message']
));
} else {
$this->response->throwJson(array(
'success' => false,
'message' => $result['message']
));
}
} catch (Exception $e) {
$this->response->throwJson(array(
'success' => false,
'message' => '获取RSS信息失败: ' . $e->getMessage()
));
}
}
}
?>