Files
UrlNav/Action.php
2026-02-23 20:15:55 +08:00

2621 lines
92 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
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()
));
}
}
}
?>