diff --git a/Action.php b/Action.php new file mode 100644 index 0000000..b551d86 --- /dev/null +++ b/Action.php @@ -0,0 +1,2621 @@ +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>/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( + '/]*name=["\']description["\'][^>]*content=["\']([^"\']*)["\'][^>]*>/i', + '/]*content=["\']([^"\']*)["\'][^>]*name=["\']description["\'][^>]*>/i', + '/]*property=["\']og:description["\'][^>]*content=["\']([^"\']*)["\'][^>]*>/i', + '/]*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(); + + // 查找 标签中的RSS + preg_match_all('/]+rel=["\'](alternate|rss|rss feed|feed)["\'][^>]+href=["\']([^"\']+)["\'][^>]*>/i', $html, $matches); + if (!empty($matches[2])) { + foreach ($matches[2] as $rssUrl) { + $rssUrls[] = $rssUrl; + } + } + + // 查找 标签中的RSS + preg_match_all('/]+href=["\']([^"\']+\.(xml|rss|atom|rdf))["\'][^>]*>/i', $html, $matches); + if (!empty($matches[1])) { + foreach ($matches[1] as $rssUrl) { + $rssUrls[] = $rssUrl; + } + } + + // 查找 标签中的 feed + preg_match_all('/]+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() + )); + } + } +} +?> \ No newline at end of file diff --git a/Manage.php b/Manage.php new file mode 100644 index 0000000..6057a7e --- /dev/null +++ b/Manage.php @@ -0,0 +1,3500 @@ +plugin('UrlNav'); +$pageSize = isset($pluginOptions->pageSize) ? intval($pluginOptions->pageSize) : 20; + +// 获取当前页码和分类 +$currentPage = isset($_GET['page']) ? intval($_GET['page']) : 1; +$currentCategory = isset($_GET['category']) ? $_GET['category'] : ''; +$currentStatus = isset($_GET['status']) ? $_GET['status'] : ''; // 新增:状态参数 +$currentHasRss = isset($_GET['has_rss']) ? $_GET['has_rss'] : ''; // 新增:RSS筛选参数 +$searchKeyword = isset($_GET['search']) ? trim($_GET['search']) : ''; + +// 获取当前星级筛选参数 +$currentStarRating = isset($_GET['star_rating']) ? $_GET['star_rating'] : ''; + +// 获取数据 +$categories = UrlNav_Plugin::getAllCategories(); +$urlsData = UrlNav_Plugin::getAllUrls($currentCategory, $currentPage, $pageSize, $searchKeyword, $currentStatus, $currentHasRss, $currentStarRating); + +// 获取每个分类的统计信息 +$categoryStats = []; +foreach ($categories as $category) { + $categoryStats[$category['id']] = UrlNav_Plugin::getCategoryStats($category['id']); +} + +// 获取状态统计(包含RSS统计) +$statusStats = UrlNav_Plugin::getStatusStats(); +?> + + + + + + + + +
+
+
+
+
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ + 总数 +
+
+ + 通连 +
+
+ + 失连 +
+
+ + 未检 +
+
+ + 通连率 +
+
+ + 有RSS +
+
+ + 无RSS +
+
+ +
+ + +
+

+ '通连', + 'offline' => '失连', + 'unchecked' => '未查' + ]; + // 新增星级名称映射 + $starNames = [ + '0' => '无星级', + '1' => '★', + '2' => '★★', + '3' => '★★★', + 'starred' => '有星级' + ]; + $filters = []; + + if ($searchKeyword) { + $filters[] = '搜索关键词:"' . htmlspecialchars($searchKeyword) . '"'; + } + + if ($currentCategory) { + $categoryName = ''; + foreach ($categories as $cat) { + if ($cat['id'] == $currentCategory) { + $categoryName = $cat['name']; + break; + } + } + if ($categoryName) { + $filters[] = '分类:"' . htmlspecialchars($categoryName) . '"'; + } + } + + if ($currentStatus && isset($statusNames[$currentStatus])) { + $filters[] = '状态:"' . $statusNames[$currentStatus] . '"'; + } + + // 新增:星级筛选提示 + if ($currentStarRating && isset($starNames[$currentStarRating])) { + $filters[] = '星级:"' . $starNames[$currentStarRating] . '"'; + } + + if ($currentHasRss) { + $filters[] = 'RSS:"' . ($currentHasRss == 'yes' ? '有' : '无') . '"'; + } + echo implode(',', $filters); + ?> +

+ + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + '; + } else { + echo ''; + } + } else { + foreach ($urls as $url) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + + + echo ''; + echo ''; + + // 状态列 + echo ''; + + // 新增:星级列 +echo ''; + + + // 新增:检查时间列 + echo ''; + + echo ''; + + // 操作列 + echo ''; + echo ''; + } + } + ?> + +
+ + + +
' . _t('没有找到符合条件的网址') . '
' . _t('暂无网址,点击"新增网址"按钮添加') . '
' . $url['id'] . '' . htmlspecialchars($url['title']) . '' . htmlspecialchars($url['url']) . ''; + if ($url['category_name']) { + echo '' . htmlspecialchars($url['category_name']) . ''; + } else { + echo '' . _t('未分类') . ''; + } + echo '' . htmlspecialchars($url['description'] ?: '-') . ''; + $statusClass = 'status-unknown'; + $statusTitle = '未查'; + + if (!empty($url['last_status_check'])) { + if ($url['is_online'] == 1) { + $statusClass = 'status-online'; + $statusTitle = '通连'; + if ($url['last_status_code']) { + $statusTitle .= ' (HTTP ' . $url['last_status_code'] . ')'; + } + $statusTitle .= ' - 最后检查: ' . date('Y-m-d', strtotime($url['last_status_check'])); + } else { + $statusClass = 'status-offline'; + $statusTitle = '失连'; + if ($url['last_status_code']) { + $statusTitle .= ' (HTTP ' . $url['last_status_code'] . ')'; + } + $statusTitle .= ' - 最后检查: ' . date('Y-m-d', strtotime($url['last_status_check'])); + } + } else if (!empty($url['created_at'])) { + $createdDays = floor((time() - strtotime($url['created_at'])) / (60 * 60 * 24)); + if ($createdDays > 30) { + $statusTitle .= ' (添加超过' . $createdDays . '天)'; + } + } + echo '
+
'; + echo '
'; +$starRating = isset($url['star_rating']) ? intval($url['star_rating']) : 0; +$starText = UrlNav_Plugin::getStarRatingText($starRating); +$starClass = $starRating > 0 ? 'has-star' : 'no-star'; +echo '' . $starText . ''; +echo ''; + if (!empty($url['last_status_check'])) { + // 将UTC时间转换为北京时间 + $beijingTime = date('Y-m-d', strtotime($url['last_status_check']) + (8 * 3600)); + echo $beijingTime; + } else { + echo '未检查'; + } + echo ''; + if (!empty($url['rss_url'])) { + echo 'RSS'; + } else { + echo ''; + } + echo ''; + echo '' . _t('编辑') . ''; + echo '' . _t('删除') . ''; + echo '
+
+ + + 1): ?> +
+
    +
  • + 1): ?> + + + + + + +
  • + + =2) + if ($totalPages >= 2) { + $showPages[] = 2; + } + + // 显示当前页前后各1页 + for ($i = max(3, $currentPage - 1); $i <= min($totalPages - 2, $currentPage + 1); $i++) { + if ($i > 2 && $i < $totalPages - 1) { + $showPages[] = $i; + } + } + + // 显示倒数第2页 + if ($totalPages > 3 && $totalPages - 1 > $currentPage + 1) { + $showPages[] = $totalPages - 1; + } + + // 显示最后一页 + if ($totalPages > 2) { + $showPages[] = $totalPages; + } + + // 去重并排序 + $showPages = array_unique($showPages); + sort($showPages); + + $prevPage = 0; + foreach ($showPages as $page) { + // 添加省略号 + if ($page - $prevPage > 1) { + echo '
  • ...
  • '; + } + + if ($page === $currentPage) { + echo '
  • ' . $page . '
  • '; + } else { + echo '
  • ' . $page . '
  • '; + } + $prevPage = $page; + } + ?> + +
  • + + + + + + +
  • +
+
+ +
+ + +
+
+
+
+
+ + +
+
+
+ + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + '; + } else { + foreach ($categories as $category) { + $stats = isset($categoryStats[$category['id']]) ? $categoryStats[$category['id']] : ['url_count' => 0, 'rss_count' => 0]; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + } + ?> + +
+ + + +
' . _t('暂无分类,点击"新增分类"按钮添加') . '
' . $category['id'] . '' . htmlspecialchars($category['name']) . '' . htmlspecialchars($category['description'] ?: '-') . '' . $category['sort_order'] . ''; + echo '' . $stats['url_count'] . ''; + echo ''; + echo '' . $stats['rss_count'] . ''; + echo ''; + echo '' . _t('编辑') . ''; + echo '' . _t('删除') . ''; + echo '
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Plugin.php b/Plugin.php new file mode 100644 index 0000000..34bc12a --- /dev/null +++ b/Plugin.php @@ -0,0 +1,5080 @@ +addItem(new Typecho_Widget_Helper_Layout('div', array('class' => 'typecho-page-title')), '

网址管理配置

'); + + // 每页显示数量 + $pageSize = new Typecho_Widget_Helper_Form_Element_Text('pageSize', null, '20', + _t('每页显示数量'), _t('后台管理中每页显示的网址数量')); + $pageSize->input->setAttribute('class', 'mini'); + $form->addInput($pageSize); + + // 是否开启网址验证 + $validateUrl = new Typecho_Widget_Helper_Form_Element_Radio('validateUrl', array( + '1' => _t('开启'), + '0' => _t('关闭') + ), '1', _t('网址验证'), _t('新增网址时是否验证网址有效性')); + $form->addInput($validateUrl); + + // ================== 全文抓取配置 ================== +$form->addItem(new Typecho_Widget_Helper_Layout('div', array('class' => 'typecho-page-title')), '

全文抓取配置

'); + +// 是否开启全文抓取 +$enableFullText = new Typecho_Widget_Helper_Form_Element_Radio('enableFullText', array( + '1' => _t('开启'), + '0' => _t('关闭') +), '0', _t('开启全文抓取'), _t('开启后会对白名单中的网站自动抓取全文')); +$form->addInput($enableFullText); + +// 白名单配置(多行文本) +$fullTextWhitelist = new Typecho_Widget_Helper_Form_Element_Textarea('fullTextWhitelist', null, + "https://wiki.eryajf.net/learning-weekly.xml|.markdown-body\nhttps://example.com/rss|#content", + _t('全文抓取白名单'), + _t('每行一个,格式:RSS地址|内容选择器(CSS选择器)
示例:https://wiki.eryajf.net/learning-weekly.xml|.post-content')); +$form->addInput($fullTextWhitelist); + +// 每个站点抓取全文的篇数 +$fullTextPerSite = new Typecho_Widget_Helper_Form_Element_Text('fullTextPerSite', null, '3', + _t('每站抓取全文篇数'), _t('每个RSS源最多抓取几篇的全文,建议1-5')); +$fullTextPerSite->input->setAttribute('class', 'mini'); +$form->addInput($fullTextPerSite); + +// 页面抓取超时时间(单篇文章) +$pageFetchTimeout = new Typecho_Widget_Helper_Form_Element_Text('pageFetchTimeout', null, '8', + _t('页面抓取超时时间(秒)'), _t('抓取单篇文章页面时的超时时间,建议8-15秒')); +$pageFetchTimeout->input->setAttribute('class', 'mini'); +$form->addInput($pageFetchTimeout); + + // ================== RSS配置 ================== + $form->addItem(new Typecho_Widget_Helper_Layout('div', array('class' => 'typecho-page-title')), '

RSS配置

'); + + // RSS页面每页显示数量 + $rssPageSize = new Typecho_Widget_Helper_Form_Element_Text('rssPageSize', null, '30', + _t('RSS页面每页显示数量'), _t('RSS信息页面每页显示的文章数量')); + $rssPageSize->input->setAttribute('class', 'mini'); + $form->addInput($rssPageSize); + + // RSS刷新间隔 + $rssRefresh = new Typecho_Widget_Helper_Form_Element_Text('rssRefresh', null, '3600', + _t('RSS刷新间隔(秒)'), _t('建议的RSS刷新间隔时间,实际执行时间由宝塔计划任务决定')); + $rssRefresh->input->setAttribute('class', 'mini'); + $form->addInput($rssRefresh); + + // 【新增】每次自动刷新网址数量 + $rssRefreshLimit = new Typecho_Widget_Helper_Form_Element_Text('rssRefreshLimit', null, '20', + _t('每次自动刷新网址数量'), _t('每次定时任务最多刷新的RSS网址数量,建议10-50,根据服务器性能调整')); + $rssRefreshLimit->input->setAttribute('class', 'mini'); + $form->addInput($rssRefreshLimit); + + // 每个站点最大文章数 + $maxFeedsPerSite = new Typecho_Widget_Helper_Form_Element_Text('maxFeedsPerSite', null, '5', + _t('每个站点最大文章数'), _t('每个RSS源最多显示的文章数量')); + $maxFeedsPerSite->input->setAttribute('class', 'mini'); + $form->addInput($maxFeedsPerSite); + + // RSS文章保留时间(改为下拉框) +$rssKeepTime = new Typecho_Widget_Helper_Form_Element_Select('rssKeepTime', + array( + '0' => _t('不自动清理(默认)'), // ← 将"默认"标识放在这里 + '86400' => _t('一天之前(24小时前)'), + '259200' => _t('三天之前(72小时前)'), + '604800' => _t('一周之前(7天前)'), + '1296000' => _t('半个月之前(15天前)'), + '2592000' => _t('一个月之前(30天前)'), + '7776000' => _t('三个月之前(90天前)'), + '15552000' => _t('半年之前(180天前)') + ), + '259200', // ← 这里改为 0,默认不清理 + _t('RSS文章保留时间'), + _t('自动清理超过此时间的RSS文章,按照文章发布时间判断,默认不自动清理')); +$form->addInput($rssKeepTime); + + // RSS最大缓存条数 + $maxCachePerSite = new Typecho_Widget_Helper_Form_Element_Text('maxCachePerSite', null, '5', + _t('每个站点最大缓存条数'), _t('每个RSS源最多缓存的文章数量,0表示不限制')); + $maxCachePerSite->input->setAttribute('class', 'mini'); + $form->addInput($maxCachePerSite); + + // 连接超时时间 + $fetchTimeout = new Typecho_Widget_Helper_Form_Element_Text('fetchTimeout', null, '5', + _t('RSS抓取超时时间(秒)'), _t('抓取RSS源时的超时时间')); + $fetchTimeout->input->setAttribute('class', 'mini'); + $form->addInput($fetchTimeout); + + // 失败重试次数 + $retryTimes = new Typecho_Widget_Helper_Form_Element_Text('retryTimes', null, '2', + _t('失败重试次数'), _t('RSS抓取失败时的重试次数')); + $retryTimes->input->setAttribute('class', 'mini'); + $form->addInput($retryTimes); + + // ================== 网站状态检查配置 ================== + $form->addItem(new Typecho_Widget_Helper_Layout('div', array('class' => 'typecho-page-title')), '

网站状态检查配置

'); + + // 状态检查超时时间 + $statusCheckTimeout = new Typecho_Widget_Helper_Form_Element_Text('statusCheckTimeout', null, '8', + _t('状态检查超时时间(秒)'), _t('检查网站状态时的超时时间')); + $statusCheckTimeout->input->setAttribute('class', 'mini'); + $form->addInput($statusCheckTimeout); + + // 每次检查的最大数量 + $statusCheckMax = new Typecho_Widget_Helper_Form_Element_Text('statusCheckMax', null, '80', + _t('每次检查最大数量'), _t('每次自动检查时最多检查的网址数量')); + $statusCheckMax->input->setAttribute('class', 'mini'); + $form->addInput($statusCheckMax); + + // ================== 定时任务配置 ================== + $form->addItem(new Typecho_Widget_Helper_Layout('div', array('class' => 'typecho-page-title')), '

定时任务配置

'); + + // RSS定时任务访问密钥 + $rssCronSecret = new Typecho_Widget_Helper_Form_Element_Text('rssCronSecret', null, self::generateSecret(), + _t('RSS定时任务密钥'), _t('用于RSS定时任务访问的密钥,请妥善保管')); + $form->addInput($rssCronSecret); + + // 状态检查定时任务访问密钥 + $statusCronSecret = new Typecho_Widget_Helper_Form_Element_Text('statusCronSecret', null, self::generateSecret(), + _t('状态检查定时任务密钥'), _t('用于状态检查定时任务访问的密钥,请妥善保管')); + $form->addInput($statusCronSecret); + } + + /** + * 个人用户的配置面板 + */ + public static function personalConfig(Typecho_Widget_Helper_Form $form) {} + + /** + * 初始化数据库路径 + */ + private static function initDbPath() + { + $dbDir = __DIR__ . '/db'; + + // 确保目录存在 + if (!is_dir($dbDir)) { + @mkdir($dbDir, 0755, true); + } + + $dbFiles = glob($dbDir . '/urlnav_*.db'); + if (!empty($dbFiles)) { + self::$dbPath = $dbFiles[0]; + } else { + $randomStr = substr(md5(uniqid(rand(), true)), 0, 10); + self::$dbPath = $dbDir . '/urlnav_' . $randomStr . '.db'; + } + } + + /** + * 生成随机密钥 + */ + private static function generateSecret() + { + return substr(md5(uniqid(rand(), true) . time()), 0, 16); + } + + public static function getCategoryStats($categoryId) { + $db = self::getDbConnection(); + + // 获取网址总数 + $stmt = $db->prepare("SELECT COUNT(*) as url_count FROM urlnav_urls WHERE category_id = ? AND is_active = 1"); + $stmt->execute([$categoryId]); + $urlCount = $stmt->fetchColumn(); + + // 获取有RSS的网址数 + $stmt = $db->prepare("SELECT COUNT(*) as rss_count FROM urlnav_urls WHERE category_id = ? AND rss_url IS NOT NULL AND rss_url != '' AND is_active = 1"); + $stmt->execute([$categoryId]); + $rssCount = $stmt->fetchColumn(); + + return [ + 'url_count' => (int)$urlCount, + 'rss_count' => (int)$rssCount + ]; + } + + /** + * 初始化数据库 + */ + private static function initDatabase() + { + if (empty(self::$dbPath)) { + self::initDbPath(); + } + + try { + $db = new PDO('sqlite:' . self::$dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // 检查分类表是否存在 + $tableCheck = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_categories'"); + if (!$tableCheck->fetch()) { + // 创建分类表 + $db->exec("CREATE TABLE urlnav_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + + // 插入默认分类 + $db->exec("INSERT INTO urlnav_categories (name, description, sort_order) VALUES + ('常用工具', '日常使用的在线工具', 1), + ('设计资源', '设计相关的素材和资源', 2), + ('开发资源', '程序开发相关资源', 3), + ('技术社区', '技术交流和学习社区', 4)"); + } + + // 检查网址表是否存在 + $tableCheck2 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_urls'"); + if (!$tableCheck2->fetch()) { + // 创建网址表 + $db->exec("CREATE TABLE urlnav_urls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + url TEXT NOT NULL, + description TEXT, + rss_url TEXT, + category_id INTEGER, + star_rating INTEGER DEFAULT 0, -- 新增:星级评分,0-3表示0-3颗星 + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + is_online INTEGER DEFAULT 1, + last_status_check DATETIME, + status_check_count INTEGER DEFAULT 0, + last_status_code INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_refresh DATETIME, + refresh_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + last_error TEXT, + FOREIGN KEY (category_id) REFERENCES urlnav_categories(id) ON DELETE SET NULL + )"); + + // 创建索引 + $db->exec("CREATE INDEX idx_category_id ON urlnav_urls(category_id)"); + $db->exec("CREATE INDEX idx_is_active ON urlnav_urls(is_active)"); + $db->exec("CREATE INDEX idx_rss_url ON urlnav_urls(rss_url)"); + $db->exec("CREATE INDEX idx_last_refresh ON urlnav_urls(last_refresh)"); + $db->exec("CREATE INDEX idx_is_online ON urlnav_urls(is_online)"); + $db->exec("CREATE INDEX idx_last_status_check ON urlnav_urls(last_status_check)"); + } + + // 创建RSS缓存表 - 修改:添加full_content字段 + $tableCheck3 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_rss_cache'"); + if (!$tableCheck3->fetch()) { + $db->exec("CREATE TABLE urlnav_rss_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url_id INTEGER NOT NULL, + feed_title TEXT NOT NULL, + feed_link TEXT NOT NULL, + feed_description TEXT, + full_content TEXT, -- 新增:完整内容字段 + pub_date DATETIME NOT NULL, + guid TEXT NOT NULL, + cached_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_fresh INTEGER DEFAULT 1, + FOREIGN KEY (url_id) REFERENCES urlnav_urls(id) ON DELETE CASCADE, + UNIQUE(url_id, guid) + )"); + + $db->exec("CREATE INDEX idx_url_id ON urlnav_rss_cache(url_id)"); + $db->exec("CREATE INDEX idx_pub_date ON urlnav_rss_cache(pub_date)"); + $db->exec("CREATE INDEX idx_cached_at ON urlnav_rss_cache(cached_at)"); + $db->exec("CREATE INDEX idx_is_fresh ON urlnav_rss_cache(is_fresh)"); + $db->exec("CREATE UNIQUE INDEX idx_url_guid ON urlnav_rss_cache(url_id, guid)"); + } + + // 创建收藏表 - 新增 + $tableCheck8 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_favorites'"); + if (!$tableCheck8->fetch()) { + $db->exec("CREATE TABLE urlnav_favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER DEFAULT 0, + feed_id INTEGER NOT NULL, + feed_title TEXT NOT NULL, + feed_link TEXT NOT NULL, + feed_description TEXT, + full_content TEXT, -- 新增:完整内容字段 + pub_date DATETIME NOT NULL, + site_title TEXT, + site_url TEXT, + category_name TEXT, + favorited_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, feed_id) + )"); + + $db->exec("CREATE INDEX idx_favorite_user_id ON urlnav_favorites(user_id)"); + $db->exec("CREATE INDEX idx_favorite_feed_id ON urlnav_favorites(feed_id)"); + $db->exec("CREATE INDEX idx_favorite_created_at ON urlnav_favorites(favorited_at)"); + } + + // 创建RSS刷新记录表 + $tableCheck4 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_refresh_log'"); + if (!$tableCheck4->fetch()) { + $db->exec("CREATE TABLE urlnav_refresh_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + refresh_type TEXT NOT NULL, + success_count INTEGER DEFAULT 0, + total_feeds INTEGER DEFAULT 0, + url_count INTEGER DEFAULT 0, + new_articles INTEGER DEFAULT 0, + error_message TEXT, + refresh_time DATETIME DEFAULT CURRENT_TIMESTAMP, + duration INTEGER DEFAULT 0, + cron_type TEXT DEFAULT 'rss' -- 新增:区分RSS和状态检查 + )"); + + $db->exec("CREATE INDEX idx_refresh_time ON urlnav_refresh_log(refresh_time)"); + $db->exec("CREATE INDEX idx_refresh_type ON urlnav_refresh_log(refresh_type)"); + $db->exec("CREATE INDEX idx_cron_type ON urlnav_refresh_log(cron_type)"); + } + + // 创建定时任务记录表 + $tableCheck5 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_cron_log'"); + if (!$tableCheck5->fetch()) { + $db->exec("CREATE TABLE urlnav_cron_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_type TEXT NOT NULL, + executed_time DATETIME DEFAULT CURRENT_TIMESTAMP, + result TEXT, + error_message TEXT + )"); + + $db->exec("CREATE INDEX idx_executed_time ON urlnav_cron_log(executed_time)"); + $db->exec("CREATE INDEX idx_cron_type ON urlnav_cron_log(cron_type)"); + } + + // 创建状态检查记录表 + $tableCheck6 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_status_log'"); + if (!$tableCheck6->fetch()) { + $db->exec("CREATE TABLE urlnav_status_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url_id INTEGER NOT NULL, + is_online INTEGER DEFAULT 0, + status_code INTEGER, + response_time INTEGER, + check_time DATETIME DEFAULT CURRENT_TIMESTAMP, + error_message TEXT, + FOREIGN KEY (url_id) REFERENCES urlnav_urls(id) ON DELETE CASCADE + )"); + + $db->exec("CREATE INDEX idx_url_id_status ON urlnav_status_log(url_id)"); + $db->exec("CREATE INDEX idx_check_time ON urlnav_status_log(check_time)"); + $db->exec("CREATE INDEX idx_is_online_status ON urlnav_status_log(is_online)"); + } + + // 创建状态检查统计表 + $tableCheck7 = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='urlnav_status_stats'"); + if (!$tableCheck7->fetch()) { + $db->exec("CREATE TABLE urlnav_status_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + total_checks INTEGER DEFAULT 0, + success_checks INTEGER DEFAULT 0, + failed_checks INTEGER DEFAULT 0, + avg_response_time REAL DEFAULT 0, + last_check_time DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )"); + + // 初始化一条记录 + $db->exec("INSERT INTO urlnav_status_stats (total_checks, success_checks, failed_checks, avg_response_time) VALUES (0, 0, 0, 0)"); + } + + // 创建更新时间触发器 + $db->exec("CREATE TRIGGER IF NOT EXISTS update_category_time + AFTER UPDATE ON urlnav_categories + BEGIN + UPDATE urlnav_categories SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END"); + + $db->exec("CREATE TRIGGER IF NOT EXISTS update_url_time + AFTER UPDATE ON urlnav_urls + BEGIN + UPDATE urlnav_urls SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END"); + + $db = null; + } catch (PDOException $e) { + error_log('UrlNav: 数据库初始化失败: ' . $e->getMessage()); + } + } + + /** + * 数据库迁移 + */ + private static function migrateDatabase() + { + try { + $db = new PDO('sqlite:' . self::$dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // 检查是否需要添加字段 + $tableInfo = $db->query("PRAGMA table_info(urlnav_urls)"); + $columns = $tableInfo->fetchAll(PDO::FETCH_ASSOC); + + $newColumns = array( + 'rss_url' => "ALTER TABLE urlnav_urls ADD COLUMN rss_url TEXT", + 'last_refresh' => "ALTER TABLE urlnav_urls ADD COLUMN last_refresh DATETIME", + 'refresh_count' => "ALTER TABLE urlnav_urls ADD COLUMN refresh_count INTEGER DEFAULT 0", + 'success_count' => "ALTER TABLE urlnav_urls ADD COLUMN success_count INTEGER DEFAULT 0", + 'failure_count' => "ALTER TABLE urlnav_urls ADD COLUMN failure_count INTEGER DEFAULT 0", + 'last_error' => "ALTER TABLE urlnav_urls ADD COLUMN last_error TEXT", + 'is_online' => "ALTER TABLE urlnav_urls ADD COLUMN is_online INTEGER DEFAULT 1", + 'last_status_check' => "ALTER TABLE urlnav_urls ADD COLUMN last_status_check DATETIME", + 'status_check_count' => "ALTER TABLE urlnav_urls ADD COLUMN status_check_count INTEGER DEFAULT 0", + 'last_status_code' => "ALTER TABLE urlnav_urls ADD COLUMN last_status_code INTEGER" + ); + + foreach ($newColumns as $columnName => $sql) { + $hasColumn = false; + foreach ($columns as $column) { + if ($column['name'] === $columnName) { + $hasColumn = true; + break; + } + } + + if (!$hasColumn) { + $db->exec($sql); + } + } + + // 检查缓存表是否需要添加is_fresh字段 + $cacheTableInfo = $db->query("PRAGMA table_info(urlnav_rss_cache)"); + $cacheColumns = $cacheTableInfo->fetchAll(PDO::FETCH_ASSOC); + + $hasIsFresh = false; + foreach ($cacheColumns as $column) { + if ($column['name'] === 'is_fresh') { + $hasIsFresh = true; + break; + } + } + + if (!$hasIsFresh) { + $db->exec("ALTER TABLE urlnav_rss_cache ADD COLUMN is_fresh INTEGER DEFAULT 1"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_is_fresh ON urlnav_rss_cache(is_fresh)"); + } + + // 检查是否需要添加star_rating字段 +$hasStarRating = false; +foreach ($columns as $column) { + if ($column['name'] === 'star_rating') { + $hasStarRating = true; + break; + } +} + +if (!$hasStarRating) { + $db->exec("ALTER TABLE urlnav_urls ADD COLUMN star_rating INTEGER DEFAULT 0"); + error_log("UrlNav: 已添加star_rating字段到urlnav_urls表"); +} + + // 检查缓存表是否需要添加full_content字段 + $hasFullContent = false; + foreach ($cacheColumns as $column) { + if ($column['name'] === 'full_content') { + $hasFullContent = true; + break; + } + } + + if (!$hasFullContent) { + $db->exec("ALTER TABLE urlnav_rss_cache ADD COLUMN full_content TEXT"); + $db->exec("ALTER TABLE urlnav_favorites ADD COLUMN full_content TEXT"); + } + + // 检查refresh_log表是否需要添加cron_type字段 + $refreshLogTableInfo = $db->query("PRAGMA table_info(urlnav_refresh_log)"); + $refreshLogColumns = $refreshLogTableInfo->fetchAll(PDO::FETCH_ASSOC); + + $hasCronType = false; + foreach ($refreshLogColumns as $column) { + if ($column['name'] === 'cron_type') { + $hasCronType = true; + break; + } + } + + if (!$hasCronType) { + $db->exec("ALTER TABLE urlnav_refresh_log ADD COLUMN cron_type TEXT DEFAULT 'rss'"); + $db->exec("CREATE INDEX IF NOT EXISTS idx_cron_type ON urlnav_refresh_log(cron_type)"); + } + + // ===== 修复关键:添加缺失的new_articles字段 ===== + $hasNewArticles = false; + foreach ($refreshLogColumns as $column) { + if ($column['name'] === 'new_articles') { + $hasNewArticles = true; + break; + } + } + + if (!$hasNewArticles) { + $db->exec("ALTER TABLE urlnav_refresh_log ADD COLUMN new_articles INTEGER DEFAULT 0"); + error_log("UrlNav: 已添加new_articles字段到urlnav_refresh_log表"); + } + + // 🔴 新增:检查是否需要添加message字段 + $hasMessage = false; + foreach ($refreshLogColumns as $column) { + if ($column['name'] === 'message') { + $hasMessage = true; + break; + } + } + + if (!$hasMessage) { + $db->exec("ALTER TABLE urlnav_refresh_log ADD COLUMN message TEXT"); + error_log("UrlNav: 已添加message字段到urlnav_refresh_log表"); + } + + // 🔴 新增:检查是否需要添加details字段 + $hasDetails = false; + foreach ($refreshLogColumns as $column) { + if ($column['name'] === 'details') { + $hasDetails = true; + break; + } + } + + if (!$hasDetails) { + $db->exec("ALTER TABLE urlnav_refresh_log ADD COLUMN details TEXT"); + error_log("UrlNav: 已添加details字段到urlnav_refresh_log表"); + } + // ===== 修复结束 ===== + + $db = null; + } catch (PDOException $e) { + error_log('UrlNav数据库迁移失败: ' . $e->getMessage()); + } + } + + + /** + * 获取数据库连接 - 优化版,解决数据库锁问题 + */ + public static function getDbConnection() + { + if (empty(self::$dbPath)) { + self::initDbPath(); + } + + if (!file_exists(self::$dbPath)) { + self::initDatabase(); + } + + $maxRetries = 3; + $retryDelay = 1; // 秒 + + for ($retry = 0; $retry < $maxRetries; $retry++) { + if ($retry > 0) { + error_log("UrlNav: 数据库连接重试 {$retry},等待 {$retryDelay} 秒..."); + sleep($retryDelay); + } + + try { + $db = new PDO('sqlite:' . self::$dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $db->exec('PRAGMA foreign_keys = ON'); + $db->exec('PRAGMA busy_timeout = 3000'); // 设置3秒超时 + $db->exec('PRAGMA journal_mode = WAL'); // 使用WAL模式提高并发性能 + + return $db; + } catch (PDOException $e) { + if (strpos($e->getMessage(), 'database is locked') !== false && $retry < $maxRetries - 1) { + continue; + } + throw new Exception('数据库连接失败: ' . $e->getMessage()); + } + } + + throw new Exception('数据库连接失败:重试' . $maxRetries . '次后仍被锁定'); + } + + /** + * 获取插件配置 + */ + public static function getConfig() + { + static $config = null; + if ($config === null) { + $options = Typecho_Widget::widget('Widget_Options'); + $config = $options->plugin('UrlNav'); + } + return $config; + } + + /** + * 获取RSS管理器 + */ + private static function getRssManager() + { + if (self::$rssManager === null) { + self::$rssManager = new UrlNav_RssManager(); + } + return self::$rssManager; + } + + public static function executeRssCronTask() +{ + // 立即设置响应头,防止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'); + + // 立即输出,让Nginx知道脚本在运行 + echo json_encode(['status' => 'starting', 'timestamp' => time()]); + flush(); + ob_flush(); + } + + $startTime = microtime(true); + + try { + error_log("UrlNav RSS定时任务: 开始执行 " . date('Y-m-d H:i:s')); + + // 设置更长的执行时间 + @set_time_limit(300); // 5分钟 + @ini_set('max_execution_time', 300); + + // 添加简单的锁检查,防止多个进程同时执行 + $lockFile = __DIR__ . '/db/rss_cron_running.lock'; + $lockTimeout = 1800; // 30分钟超时 + + if (file_exists($lockFile)) { + $lockTime = @filemtime($lockFile); + if ($lockTime && (time() - $lockTime) < $lockTimeout) { + error_log("UrlNav RSS定时任务: 跳过执行,已在运行中"); + return array( + 'success' => false, + 'message' => '定时任务已在运行中,跳过本次执行', + 'timestamp' => time() + ); + } + // 锁已超时,删除它 + @unlink($lockFile); + } + + // 创建锁文件 + @touch($lockFile); + @file_put_contents($lockFile, "Started at: " . date('Y-m-d H:i:s')); + + register_shutdown_function(function() use ($lockFile) { + if (file_exists($lockFile)) { + @unlink($lockFile); + error_log("UrlNav: shutdown函数删除RSS锁文件"); + } + }); + + // 执行刷新任务 + $refreshResult = self::refreshAllRssFeeds(true); + + $duration = round(microtime(true) - $startTime, 2); + + // 删除锁文件 + if (file_exists($lockFile)) { + @unlink($lockFile); + } + + // 🆕 修改:确保result包含RSS地址信息 + $result = array( + 'success' => $refreshResult['success'], + 'refreshed' => true, + 'refresh_result' => $refreshResult, + 'timestamp' => time(), + 'duration' => $duration, + 'message' => $refreshResult['message'], + // 🆕 关键:直接包含RSS地址信息 + 'successRssUrls' => $refreshResult['successRssUrls'] ?? array(), + 'failedRssUrls' => $refreshResult['failedRssUrls'] ?? array() + ); + + // 记录日志(会自动将上面的result转为JSON存入数据库) + self::logCron('rss_auto_refresh', $result); + + error_log("UrlNav RSS定时任务: 执行完成,耗时 {$duration} 秒"); + + return $result; + + } catch (Exception $e) { + error_log("UrlNav RSS定时任务异常: " . $e->getMessage()); + + // 确保锁文件被删除 + $lockFile = __DIR__ . '/db/rss_cron_running.lock'; + if (file_exists($lockFile)) { + @unlink($lockFile); + } + + return array( + 'success' => false, + 'error' => $e->getMessage(), + 'timestamp' => time(), + 'message' => 'RSS定时任务执行异常', + 'successRssUrls' => array(), + 'failedRssUrls' => array() + ); + } +} + + /** + * 执行状态检查定时任务 - 完全移除锁机制 + */ + public static function executeStatusCronTask() + { + try { + $startTime = microtime(true); + error_log("UrlNav 状态检查定时任务: 开始执行 " . date('Y-m-d H:i:s')); + + // 修改这里:调用正确的自动检查方式 + $statusResult = self::manualCheckStatus(null, false); // $urlIds=null, $isBatchCheck=false + + $endTime = microtime(true); + $duration = round($endTime - $startTime, 2); + + // 更新状态检查统计 + self::updateStatusStats($statusResult); + + // 记录状态检查专用的定时任务日志 + self::logCron('status_auto_check', json_encode(array_merge($statusResult, array( + 'duration' => $duration, + 'timestamp' => time() + )))); + + if ($statusResult['success']) { + error_log("UrlNav 状态检查定时任务: 执行成功,耗时 {$duration} 秒"); + return array( + 'success' => true, + 'status_checked' => $statusResult['total'] > 0, + 'status_result' => $statusResult, + 'timestamp' => time(), + 'duration' => $duration, + 'message' => '状态检查定时任务执行成功' + ); + } else { + error_log("UrlNav 状态检查定时任务: 执行失败: " . $statusResult['message']); + return array( + 'success' => false, + 'status_checked' => false, + 'status_result' => $statusResult, + 'timestamp' => time(), + 'duration' => $duration, + 'message' => '状态检查定时任务执行失败' + ); + } + + } catch (Exception $e) { + error_log("UrlNav 状态检查定时任务异常: " . $e->getMessage()); + return array( + 'success' => false, + 'error' => $e->getMessage(), + 'timestamp' => time(), + 'message' => '状态检查定时任务执行异常' + ); + } + } + + /** + * 通用的锁定任务执行器 - 优化版,减少锁竞争 + */ + private static function executeLockedTask($lockFile, $taskType, $callback) + { + $lockTimeout = 3600; // 延长到1小时超时 + + // 简化的锁检查:如果锁文件存在且未超时,直接跳过 + if (file_exists($lockFile)) { + $lockTime = @filemtime($lockFile); + if ($lockTime && (time() - $lockTime) < $lockTimeout) { + $lockDuration = time() - $lockTime; + error_log("UrlNav {$taskType}: 跳过执行,锁文件存在 {$lockDuration} 秒"); + return array( + 'success' => false, + 'message' => "{$taskType}定时任务正在运行,跳过本次执行", + 'timestamp' => time(), + 'lock_time' => $lockTime, + 'lock_duration' => $lockDuration + ); + } + // 锁已超时,删除它 + @unlink($lockFile); + error_log("UrlNav {$taskType}: 删除超时的锁文件(已存在超过 {$lockTimeout} 秒)"); + } + + // 创建锁文件 + if (!@touch($lockFile)) { + error_log("UrlNav {$taskType}: 无法创建锁文件"); + return array( + 'success' => false, + 'message' => '无法创建锁文件', + 'timestamp' => time() + ); + } + + // 在锁文件中记录开始时间 + file_put_contents($lockFile, "Started at: " . date('Y-m-d H:i:s') . "\nTask type: {$taskType}"); + + error_log("UrlNav {$taskType}: 开始执行定时任务 " . date('Y-m-d H:i:s')); + + try { + // 确保锁文件会被删除(即使脚本意外终止) + register_shutdown_function(function() use ($lockFile, $taskType) { + if (file_exists($lockFile)) { + $lockDuration = time() - filemtime($lockFile); + @unlink($lockFile); + error_log("UrlNav {$taskType}: shutdown函数删除锁文件,锁持续了 {$lockDuration} 秒"); + } + }); + + // 执行回调函数 + $result = $callback(); + + // 删除锁文件 + if (file_exists($lockFile)) { + $lockDuration = time() - filemtime($lockFile); + @unlink($lockFile); + error_log("UrlNav {$taskType}: 任务完成,删除锁文件,任务耗时 {$lockDuration} 秒"); + } + + return $result; + + } catch (Exception $e) { + // 确保锁文件被删除 + if (file_exists($lockFile)) { + $lockDuration = time() - filemtime($lockFile); + @unlink($lockFile); + error_log("UrlNav {$taskType}: 异常时删除锁文件,锁持续了 {$lockDuration} 秒"); + } + + error_log("UrlNav {$taskType}定时任务异常: " . $e->getMessage()); + self::logCron('error', $e->getMessage()); + + return array( + 'success' => false, + 'error' => $e->getMessage(), + 'timestamp' => time(), + 'message' => "{$taskType}定时任务执行异常" + ); + } + } + + /** + * 手动解锁定时任务(供调试使用) + */ + public static function unlockCron($cronType = 'rss') + { + if ($cronType === 'rss') { + $lockFile = __DIR__ . '/db/rss_cron.lock'; + } elseif ($cronType === 'status') { + $lockFile = __DIR__ . '/db/status_cron.lock'; + } else { + $lockFile = __DIR__ . '/db/cron.lock'; + } + + if (file_exists($lockFile)) { + if (@unlink($lockFile)) { + error_log("UrlNav: 手动解锁{$cronType}成功"); + return array( + 'success' => true, + 'message' => "{$cronType}定时任务锁已解除", + 'timestamp' => time() + ); + } else { + error_log("UrlNav: 手动解锁{$cronType}失败"); + return array( + 'success' => false, + 'message' => '无法删除锁文件', + 'timestamp' => time() + ); + } + } else { + return array( + 'success' => true, + 'message' => "{$cronType}没有锁文件存在", + 'timestamp' => time() + ); + } + } + + public static function refreshAllRssFeeds($isCron = false) +{ + $startTime = microtime(true); + + // 🆕 新增:在定时任务中自动清理过期缓存 + if ($isCron) { + self::cleanExpiredCache(); + } + + // 立即设置响应头,避免502 + if ($isCron && !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'); + + // 立即输出一些内容,让Nginx知道脚本还在运行 + echo '{"status":"starting","message":"RSS刷新任务开始...","timestamp":' . time() . '}'; + flush(); + ob_flush(); + } + + // 设置更长的执行时间 + if ($isCron) { + @set_time_limit(300); // 5分钟 + @ini_set('max_execution_time', 300); + } + + try { + $db = self::getDbConnection(); + + // 使用后台配置的数量 + $config = self::getConfig(); + $limit = intval($config->rssRefreshLimit ?? 10); + $limit = max(1, min($limit, 30)); // 限制在1-30之间 + + error_log("===== UrlNav RSS刷新开始,时间: " . date('Y-m-d H:i:s') . " ====="); + error_log("配置数量: {$limit}"); + + // 优化查询:优先处理从未刷新或很久没刷新的 + // 关键修复:添加时间条件,避免重复刷新刚刷过的 + $sql = " + SELECT id, rss_url, url, title, last_refresh, failure_count, success_count, created_at + FROM urlnav_urls + WHERE is_active = 1 + AND rss_url IS NOT NULL + AND TRIM(rss_url) != '' + AND ( + -- 从未刷新过的 + last_refresh IS NULL + -- 或者超过1小时没刷新的 + OR last_refresh < datetime('now', '-1 hour') + -- 或者失败次数多且超过30分钟没重试 + OR (failure_count > success_count AND last_refresh < datetime('now', '-30 minutes')) + ) + ORDER BY + CASE + -- 最高优先级:从未刷新过的 + WHEN last_refresh IS NULL THEN 0 + -- 次高优先级:失败次数多于成功次数的 + WHEN failure_count > success_count THEN 1 + -- 中等优先级:新添加的网址(最近3天内) + WHEN created_at > datetime('now', '-3 days') THEN 2 + -- 低优先级:正常的 + ELSE 3 + END, + -- 按刷新时间从早到晚排序 + CASE + WHEN last_refresh IS NULL THEN created_at + ELSE last_refresh + END ASC + LIMIT ? + "; + + $stmt = $db->prepare($sql); + $stmt->execute(array($limit)); + $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // 如果没有符合条件的,放宽条件选择一些 + if (empty($urls)) { + error_log("UrlNav: 没有需要立即刷新的RSS源,选择一些较久没刷新的"); + + $sql = " + SELECT id, rss_url, url, title, last_refresh, failure_count, success_count + FROM urlnav_urls + WHERE is_active = 1 + AND rss_url IS NOT NULL + AND TRIM(rss_url) != '' + ORDER BY last_refresh ASC NULLS FIRST + LIMIT ? + "; + + $stmt = $db->prepare($sql); + $stmt->execute(array(min($limit, 5))); // 少选几个 + $urls = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + if (empty($urls)) { + error_log("UrlNav: 没有需要刷新的RSS网址"); + return array( + 'success' => true, + 'message' => '没有需要刷新的RSS网址', + 'successCount' => 0, + 'failureCount' => 0, + 'newArticles' => 0, + 'totalFeeds' => 0, + 'urlCount' => 0, + 'successRssUrls' => array(), + 'failedRssUrls' => array() + ); + } + + error_log("UrlNav: 获取到 " . count($urls) . " 个需要刷新的RSS源"); + + // 记录获取到的URL信息 + foreach ($urls as $url) { + $refreshStatus = $url['last_refresh'] ? + "最后刷新: " . $url['last_refresh'] : + "从未刷新"; + error_log("UrlNav: 选中 - ID: {$url['id']}, {$refreshStatus}, URL: {$url['rss_url']}"); + } + + $successCount = 0; + $failureCount = 0; + $totalFeeds = 0; + $newArticles = 0; + + // 🆕 修改:记录成功和失败的RSS地址 + $successRssUrls = array(); + $failedRssUrls = array(); + + // 配置参数 + $timeout = intval($config->fetchTimeout ?? 15); // 默认15秒 + $retryTimes = intval($config->retryTimes ?? 2); // 重试2次 + $maxFeeds = intval($config->maxFeedsPerSite ?? 20); // 每个站点20条 + + // 关键:定期输出内容,保持连接活跃 + $lastOutputTime = $startTime; + + foreach ($urls as $index => $url) { + $currentTime = microtime(true); + $elapsedTime = $currentTime - $startTime; + + // 检查总执行时间(4分钟限制) + if ($isCron && $elapsedTime > 240) { + error_log('UrlNav: 接近总超时(4分钟),停止处理'); + break; + } + + // 每3秒输出一次,保持连接活跃(防502关键) + if ($isCron && ($currentTime - $lastOutputTime) > 3) { + if (!headers_sent()) { + echo '{"status":"processing","progress":"' . ($index+1) . '/' . count($urls) . '","timestamp":' . time() . '}'; + flush(); + ob_flush(); + } + $lastOutputTime = $currentTime; + } + + try { + error_log("UrlNav: [开始] 处理RSS #" . ($index+1) . " - ID: " . $url['id'] . ", URL: " . $url['rss_url']); + error_log("UrlNav: 最后刷新时间: " . ($url['last_refresh'] ?: '从未刷新')); + + $urlResult = self::refreshSingleRssUrl($url, $timeout, $retryTimes, $maxFeeds); + + if ($urlResult['success']) { + $successCount++; + $newArticles += $urlResult['new_articles']; + $totalFeeds += $urlResult['total_feeds']; + + // 🆕 记录成功的RSS地址 + $successRssUrls[] = $url['rss_url']; + + error_log("UrlNav: [成功] ID: " . $url['id'] . ", 新增文章: " . $urlResult['new_articles'] . ", RSS: " . $url['rss_url']); + } else { + $failureCount++; + + // 🆕 记录失败的RSS地址 + $failedRssUrls[] = $url['rss_url']; + + error_log("UrlNav: [失败] ID: " . $url['id'] . ", 错误: " . ($urlResult['error'] ?? '未知错误') . ", RSS: " . $url['rss_url']); + } + + // 短暂休息,避免对目标服务器压力过大 + if ($index < count($urls) - 1) { // 不是最后一个时休息 + usleep(800000); // 0.8秒休息 + } + + } catch (Exception $e) { + $failureCount++; + + // 🆕 记录异常的RSS地址 + $failedRssUrls[] = $url['rss_url'] . " [异常]"; + + error_log('UrlNav: [异常] ID: ' . $url['id'] . ', RSS: ' . $url['rss_url'] . ', 异常: ' . $e->getMessage()); + } + } + + $duration = round(microtime(true) - $startTime, 2); + + // 记录日志 - 现在传递成功和失败的RSS地址 + self::logRefresh($isCron ? 'cron' : 'manual', $successCount, $totalFeeds, + count($urls), $newArticles, null, $duration, 'rss', + $successRssUrls, $failedRssUrls); + + $message = "刷新完成:成功 {$successCount} 个,失败 {$failureCount} 个"; + $result = array( + 'success' => $successCount > 0 || count($urls) == 0, + 'successCount' => $successCount, + 'failureCount' => $failureCount, + 'newArticles' => $newArticles, + 'totalFeeds' => $totalFeeds, + 'urlCount' => count($urls), + 'duration' => $duration, + 'message' => $message, + // 🆕 修改:返回成功和失败的RSS地址 + 'successRssUrls' => $successRssUrls, + 'failedRssUrls' => $failedRssUrls + ); + + error_log("UrlNav: [完成] RSS刷新完成,耗时 {$duration} 秒,{$message}"); + error_log("===== UrlNav RSS刷新结束 ====="); + + return $result; + + } catch (Exception $e) { + error_log('UrlNav: [全局异常] 刷新失败: ' . $e->getMessage()); + error_log("===== UrlNav RSS刷新异常结束 ====="); + return array( + 'success' => false, + 'message' => '刷新失败: ' . $e->getMessage(), + 'successRssUrls' => array(), + 'failedRssUrls' => array() + ); + } +} + /** + * 获取RSS刷新状态统计 + */ + public static function getRssRefreshStatus() + { + try { + $db = self::getDbConnection(); + + // 获取统计信息 + $stats = array(); + + // 总RSS源数量 + $stmt = $db->query("SELECT COUNT(*) as total FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != ''"); + $stats['total_rss_sources'] = $stmt->fetchColumn(); + + // 从未刷新的数量 + $stmt = $db->query("SELECT COUNT(*) as never_refreshed FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != '' AND last_refresh IS NULL"); + $stats['never_refreshed'] = $stmt->fetchColumn(); + + // 今天刷新的数量 + $stmt = $db->query("SELECT COUNT(*) as today_refreshed FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != '' AND date(last_refresh) = date('now')"); + $stats['today_refreshed'] = $stmt->fetchColumn(); + + // 最近7天刷新的数量 + $stmt = $db->query("SELECT COUNT(*) as week_refreshed FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != '' AND last_refresh >= datetime('now', '-7 days')"); + $stats['week_refreshed'] = $stmt->fetchColumn(); + + // 最久未刷新的时间 + $stmt = $db->query("SELECT MIN(last_refresh) as oldest_refresh FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != '' AND last_refresh IS NOT NULL"); + $oldest = $stmt->fetchColumn(); + $stats['oldest_refresh'] = $oldest; + if ($oldest) { + $stats['oldest_days'] = round((time() - strtotime($oldest)) / 86400, 1); + } + + // 需要刷新的数量(超过1天没刷新的) + $stmt = $db->query("SELECT COUNT(*) as need_refresh FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != '' AND (last_refresh IS NULL OR last_refresh < datetime('now', '-1 day'))"); + $stats['need_refresh'] = $stmt->fetchColumn(); + + // 成功率统计 + $stmt = $db->query("SELECT + SUM(success_count) as total_success, + SUM(failure_count) as total_failure, + SUM(refresh_count) as total_refreshes + FROM urlnav_urls WHERE is_active = 1 AND rss_url IS NOT NULL AND TRIM(rss_url) != ''"); + $countStats = $stmt->fetch(PDO::FETCH_ASSOC); + + $stats['total_success'] = $countStats['total_success'] ?? 0; + $stats['total_failure'] = $countStats['total_failure'] ?? 0; + $stats['total_refreshes'] = $countStats['total_refreshes'] ?? 0; + $stats['success_rate'] = $stats['total_refreshes'] > 0 ? + round(($stats['total_success'] / $stats['total_refreshes']) * 100, 1) : 0; + + return $stats; + + } catch (Exception $e) { + error_log('UrlNav: 获取刷新状态失败: ' . $e->getMessage()); + return array(); + } + } + + + private static function refreshSingleRssUrl($url, $timeout = 8, $retryTimes = 1, $maxFeeds = 10) + { + $urlId = $url['id']; + $rssUrl = trim($url['rss_url']); + + error_log("UrlNav: === 开始处理RSS ID: {$urlId} ==="); + error_log("UrlNav: RSS URL: {$rssUrl}"); + + try { + $db = self::getDbConnection(); + + // 更新刷新统计 + $stmt = $db->prepare("UPDATE urlnav_urls SET refresh_count = refresh_count + 1 WHERE id = ?"); + $stmt->execute(array($urlId)); + error_log("UrlNav: 更新刷新统计成功"); + + // 解析RSS内容 + error_log("UrlNav: 开始解析RSS内容..."); + $feeds = self::parseRssFeedWithRetry($rssUrl, $retryTimes, $timeout); + error_log("UrlNav: RSS解析完成,获取到 " . count($feeds) . " 篇文章"); + + if (empty($feeds)) { + error_log("UrlNav: 没有获取到文章数据"); + + $stmt = $db->prepare(" + UPDATE urlnav_urls SET + last_refresh = CURRENT_TIMESTAMP, + last_error = '无可用数据' + WHERE id = ? + "); + $stmt->execute(array($urlId)); + + error_log("UrlNav: === 处理完成(无数据)==="); + return array( + 'success' => true, + 'new_articles' => 0, + 'total_feeds' => 0, + 'error' => null + ); + } + + // 限制每个站点最大文章数 + $feeds = array_slice($feeds, 0, $maxFeeds); + error_log("UrlNav: 限制后文章数: " . count($feeds)); + + $addedCount = 0; + foreach ($feeds as $feedIndex => $feed) { + try { + // 确保所有必要字段都有值 + $title = !empty($feed['title']) ? substr($feed['title'], 0, 255) : '无标题'; + $link = !empty($feed['link']) ? substr($feed['link'], 0, 500) : $url['url']; + $description = !empty($feed['description']) ? substr($feed['description'], 0, 1000) : ''; + $fullContent = !empty($feed['full_content']) ? substr($feed['full_content'], 0, 5000) : $description; // 使用完整内容,如果不存在则使用描述 + $pubDate = !empty($feed['pubDate']) ? $feed['pubDate'] : date('Y-m-d H:i:s'); + $guid = !empty($feed['guid']) ? substr($feed['guid'], 0, 255) : md5($link . $pubDate); + + error_log("UrlNav: 处理文章 #" . ($feedIndex+1) . ": {$title}"); + + // 使用INSERT OR IGNORE避免冲突 + $stmt = $db->prepare(" + INSERT OR IGNORE INTO urlnav_rss_cache + (url_id, feed_title, feed_link, feed_description, full_content, pub_date, guid, cached_at, is_fresh) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 1) + "); + + $stmt->execute(array( + $urlId, + $title, + $link, + $description, + $fullContent, + $pubDate, + $guid + )); + + if ($stmt->rowCount() > 0) { + $addedCount++; + error_log("UrlNav: 文章 #" . ($feedIndex+1) . " 插入成功"); + } else { + error_log("UrlNav: 文章 #" . ($feedIndex+1) . " 已存在,跳过"); + } + + } catch (Exception $e) { + error_log('UrlNav: 文章处理异常: ' . $e->getMessage()); + // 继续处理下一篇文章 + continue; + } + } + + // 更新URL统计信息 + $stmt = $db->prepare(" + UPDATE urlnav_urls SET + success_count = success_count + 1, + last_refresh = CURRENT_TIMESTAMP, + last_error = NULL + WHERE id = ? + "); + $stmt->execute(array($urlId)); + + error_log("UrlNav: 成功解析RSS - ID: {$urlId}, 获取到 " . count($feeds) . " 篇文章, 新增 {$addedCount} 篇"); + error_log("UrlNav: === 处理完成(成功)==="); + + return array( + 'success' => true, + 'new_articles' => $addedCount, + 'total_feeds' => count($feeds), + 'error' => null + ); + + } catch (Exception $e) { + // 记录错误信息 + $errorMessage = substr($e->getMessage(), 0, 500); + error_log("UrlNav: RSS解析失败 - 错误: {$errorMessage}"); + + $stmt = $db->prepare(" + UPDATE urlnav_urls SET + failure_count = failure_count + 1, + last_refresh = CURRENT_TIMESTAMP, + last_error = ? + WHERE id = ? + "); + $stmt->execute(array($errorMessage, $urlId)); + + error_log("UrlNav: === 处理完成(失败)==="); + + return array( + 'success' => false, + 'new_articles' => 0, + 'total_feeds' => 0, + 'error' => $errorMessage + ); + } + } + + /** + * 获取需要刷新的网址数量 - 新增方法 + */ + public static function getUrlsNeedingRefresh() + { + try { + $db = self::getDbConnection(); + + $sql = " + SELECT COUNT(*) as count FROM urlnav_urls + WHERE is_active = 1 + AND rss_url IS NOT NULL + AND TRIM(rss_url) != '' + AND ( + last_refresh IS NULL + OR last_refresh <= datetime('now', '-1 hour') + ) + "; + + $stmt = $db->query($sql); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + return $result['count'] ?? 0; + } catch (Exception $e) { + error_log('UrlNav: 获取需要刷新的网址数量失败: ' . $e->getMessage()); + return 0; + } + } + + /** + * 带重试机制的RSS解析 - 优化版 + */ + private static function parseRssFeedWithRetry($rssUrl, $retryTimes = 1, $timeout = 8) + { + $lastError = null; + + for ($i = 0; $i <= $retryTimes; $i++) { + try { + if ($i > 0) { + // 重试前等待一段时间 + sleep($i * 2); + error_log("UrlNav: RSS重试第{$i}次: {$rssUrl}"); + } + + $feeds = self::parseRssFeed($rssUrl, $timeout); + return $feeds; + + } catch (Exception $e) { + $lastError = $e; + $errorMsg = $e->getMessage(); + + // 如果是DNS错误,尝试使用IP直接访问(针对特定域名) + if (strpos($errorMsg, 'getaddrinfo failed') !== false && strpos($rssUrl, 'windful.cn') !== false) { + // 尝试使用IP访问(需要你知道windful.cn的IP) + // $rssUrl = str_replace('https://windful.cn/', 'https://[IP地址]/', $rssUrl); + error_log("UrlNav: DNS解析失败,建议检查windful.cn域名是否正常"); + } + + if ($i < $retryTimes) { + error_log("UrlNav: RSS解析失败,第" . ($i+1) . "次重试: " . $errorMsg); + } + } + } + + // 所有重试都失败 + throw new Exception("RSS解析失败: " . $lastError->getMessage()); + } + + +/** + * 解析RSS源 - 完整功能增强版(修改全文字段逻辑) + */ +private static function parseRssFeed($rssUrl, $timeout = 8) +{ + error_log("UrlNav: >>> 开始解析RSS: {$rssUrl}"); + + try { + // 设置超时时间(保持原样) + $context = stream_context_create(array( + 'http' => array( + 'timeout' => $timeout, + 'ignore_errors' => true, + 'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n" . + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n" . + "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n" + ), + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + )); + + error_log("UrlNav: 尝试获取RSS内容..."); + $content = @file_get_contents($rssUrl, false, $context); + + if ($content === false) { + $error = error_get_last(); + $errorMsg = $error['message'] ?? '未知错误'; + error_log("UrlNav: file_get_contents失败: {$errorMsg}"); + + if (isset($http_response_header)) { + error_log("UrlNav: HTTP响应头: " . implode(" | ", $http_response_header)); + } + + throw new Exception('无法获取RSS内容: ' . $errorMsg); + } + + error_log("UrlNav: 获取内容成功,长度: " . strlen($content) . " 字节"); + + // 检查HTTP状态码(保持原样) + if (isset($http_response_header[0])) { + error_log("UrlNav: HTTP状态: {$http_response_header[0]}"); + if (strpos($http_response_header[0], '404') !== false) { + throw new Exception('RSS源不存在 (404)'); + } + if (strpos($http_response_header[0], '403') !== false) { + throw new Exception('拒绝访问 (403)'); + } + if (strpos($http_response_header[0], '500') !== false) { + throw new Exception('服务器内部错误 (500)'); + } + } + + if (empty($content) || trim($content) === '') { + error_log("UrlNav: RSS内容为空"); + throw new Exception('RSS内容为空'); + } + + // 处理可能存在的BOM头(保持原样) + if (substr($content, 0, 3) == "\xEF\xBB\xBF") { + $content = substr($content, 3); + error_log("UrlNav: 已移除BOM头"); + } + + // 简单的XML修复(保持原样) + $content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $content); + $content = preg_replace('/&(?!(amp|lt|gt|quot|apos|#\d+);)/', '&', $content); + + // 🆕 增强:尝试多种XML解析方式,确保兼容性 + libxml_use_internal_errors(true); + libxml_clear_errors(); + + $xml = null; + + // 方式1:先尝试DOMDocument(最兼容WordPress/Typecho) + try { + $dom = new DOMDocument(); + $dom->recover = true; + $dom->strictErrorChecking = false; + + if (@$dom->loadXML($content)) { + error_log("UrlNav: 使用DOMDocument解析成功"); + $xml = simplexml_import_dom($dom); + } + } catch (Exception $e) { + error_log("UrlNav: DOMDocument解析失败: " . $e->getMessage()); + } + + // 方式2:如果DOMDocument失败,使用SimpleXML + if ($xml === null) { + error_log("UrlNav: 尝试SimpleXML解析..."); + $xml = simplexml_load_string($content, 'SimpleXMLElement', LIBXML_NOCDATA); + } + + if ($xml === false) { + $errorMsg = 'XML解析失败'; + $xmlErrors = libxml_get_errors(); + if (!empty($xmlErrors)) { + $errorMsg .= ': ' . $xmlErrors[0]->message; + error_log("UrlNav: XML错误: " . $xmlErrors[0]->message); + } + libxml_clear_errors(); + + if (strpos($content, 'fullTextPerSite ?? 3); + $pageFetchTimeout = intval($config->pageFetchTimeout ?? 10); + $fullTextCount = 0; // 计数器 + + // 检查是否在白名单中(保持原样) + $selector = self::isInFullTextWhitelist($rssUrl); + $isInWhitelist = ($selector !== false); + + error_log("UrlNav: 白名单检查 - 是否在白名单: " . ($isInWhitelist ? '是' : '否') . + ($isInWhitelist ? ",选择器: {$selector}" : "")); + + // ========== RSS格式解析 ========== + if (isset($xml->channel) && isset($xml->channel->item)) { + error_log("UrlNav: 检测到RSS格式 (channel->item)"); + + foreach ($xml->channel->item as $itemIndex => $item) { + // 🆕 增强:安全处理每个item,防止一个item失败影响全部 + try { + // 基础内容获取(保持原样) + $fullContent = ''; + $description = isset($item->description) ? (string)$item->description : ''; + $articleTitle = isset($item->title) ? (string)$item->title : '无标题文章'; + $articleLink = isset($item->link) ? (string)$item->link : ''; + + // 确保标题不为空(保持原样) + if (empty($articleTitle)) { + $articleTitle = '未命名文章 ' . date('Y-m-d H:i:s'); + } + + // 如果链接为空,尝试使用guid(保持原样) + if (empty($articleLink) && isset($item->guid)) { + $articleLink = (string)$item->guid; + } + + error_log("UrlNav: 处理文章: {$articleTitle}"); + + // 🆕 增强:更好的content:encoded提取(处理WordPress/Typecho) + $namespaces = $item->getNamespaces(true); + + // 1. 优先获取content:encoded(WordPress完整内容) + $encodedContent = ''; + if (isset($namespaces['content'])) { + $contentNs = $item->children($namespaces['content']); + if (isset($contentNs->encoded)) { + $encodedContent = (string)$contentNs->encoded; + if (!empty($encodedContent) && trim($encodedContent) !== '') { + $fullContent = $encodedContent; + error_log("UrlNav: ✓ 找到content:encoded完整内容,长度: " . strlen($fullContent)); + } + } + } + + // 2. 如果没有content:encoded,使用description + if (empty($fullContent) && !empty($description)) { + $fullContent = $description; + error_log("UrlNav: 使用description作为内容,长度: " . strlen($description)); + } + + // 3. 尝试dc:description命名空间 + if (empty($fullContent) && isset($namespaces['dc'])) { + $dcNs = $item->children($namespaces['dc']); + if (isset($dcNs->description) && !empty((string)$dcNs->description)) { + $fullContent = (string)$dcNs->description; + error_log("UrlNav: 找到dc:description内容"); + } + } + + // 4. 尝试item的直接子元素(保持原样) + if (empty($fullContent)) { + foreach ($item->children() as $child) { + $childName = $child->getName(); + $childContent = (string)$child; + + // 跳过已知的短字段 + if (in_array($childName, ['title', 'link', 'guid', 'pubDate', 'author', 'category'])) { + continue; + } + + // 如果子元素内容较长,可能是文章内容 + if (strlen($childContent) > 100) { + $fullContent = $childContent; + error_log("UrlNav: 从子元素 {$childName} 提取内容"); + break; + } + } + } + + // ===== 页面抓取判断逻辑(完全保持不变) ===== + $pageContent = null; + $rssContentLength = strlen($fullContent); + + // 判断逻辑:只有在白名单中且未超过限制才抓取 + if ($isInWhitelist && $fullTextCount < $fullTextPerSite) { + $needPageFetch = true; + $fullTextCount++; + error_log("UrlNav: 白名单抓取全文 #{$fullTextCount}/{$fullTextPerSite} - {$articleTitle}"); + } else { + $needPageFetch = false; + if ($isInWhitelist && $fullTextCount >= $fullTextPerSite) { + error_log("UrlNav: 已达白名单抓取限制({$fullTextCount}/{$fullTextPerSite}),跳过"); + } elseif (!$isInWhitelist) { + error_log("UrlNav: 非白名单网站,使用RSS摘要({$rssContentLength}字符),不抓取全文"); + } + } + + // 执行页面抓取(仅白名单) + if ($needPageFetch && !empty($articleLink)) { + // 短暂延迟,避免对服务器压力过大 + if ($itemIndex > 0) { + usleep(rand(300000, 800000)); // 300-800ms延迟 + } + + // 使用选择器抓取 + $pageContent = self::fetchFullContentWithSelector($articleLink, $selector, $pageFetchTimeout); + + if (!empty($pageContent)) { + $pageLength = strlen($pageContent); + + if ($pageLength > $rssContentLength + 300) { + $fullContent = $pageContent; + error_log("UrlNav: ✓ 页面抓取成功,获得 {$pageLength} 字符内容"); + } elseif ($pageLength > 0) { + // 合并内容 + $fullContent = $fullContent . "\n\n[页面补充内容]\n" . $pageContent; + error_log("UrlNav: ✓ 合并页面内容,总长度: " . strlen($fullContent)); + } else { + error_log("UrlNav: ✗ 页面抓取未获得内容"); + } + } else { + error_log("UrlNav: ✗ 页面抓取失败"); + } + } + // ===== 页面抓取逻辑结束 ===== + + // 🔴 修改:非白名单网站全文字段处理逻辑 + if (!$isInWhitelist) { + // 非白名单网站,判断 description 或 content:encoded 是否大于500字 + $descriptionLength = strlen($description); + $encodedContentLength = strlen($encodedContent); + + // 只要 description 或 content:encoded 任意一个大于500字就存入全文 + if ($descriptionLength >= 500 || $encodedContentLength >= 500) { + // 有足够长的内容,存入全文字段 + // 内容清理和截断 + $fullContent = preg_replace('/\s+/', ' ', $fullContent); + if (strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + error_log("UrlNav: 内容过长,已截断至10000字符"); + } + error_log("UrlNav: 非白名单网站,description({$descriptionLength})或content:encoded({$encodedContentLength})长度≥500字,存入全文字段"); + } else { + // 内容太短,留空不存储 + $fullContent = ''; + error_log("UrlNav: 非白名单网站,description({$descriptionLength})和content:encoded({$encodedContentLength})都小于500字,全文字段留空"); + } + } else { + // 白名单网站保持原有逻辑 + if (!empty($fullContent)) { + // 移除过多的空白字符 + $fullContent = preg_replace('/\s+/', ' ', $fullContent); + + // 截断到合理长度 + if (strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + error_log("UrlNav: 内容过长,已截断至10000字符"); + } + } else { + error_log("UrlNav: 警告:未找到任何内容"); + $fullContent = $description; + } + } + + // 获取发布时间(保持原样) + $pubDate = date('Y-m-d H:i:s', strtotime((string)$item->pubDate)); + + // 获取GUID(保持原样) + $guid = (string)$item->guid; + + $feeds[] = array( + 'title' => $articleTitle, + 'link' => $articleLink, + 'description' => $description, + 'full_content' => $fullContent, // 🔴 现在非白名单网站可能为空 + 'pubDate' => $pubDate, + 'guid' => $guid + ); + + error_log("UrlNav: ✓ 文章解析完成: {$articleTitle}"); + + } catch (Exception $e) { + // 🆕 增强:单个item失败不影响其他item + error_log("UrlNav: 文章处理失败,跳过: " . $e->getMessage()); + continue; + } + } + } + // ========== Atom格式解析(保持原样但应用相同逻辑修改) ========== + elseif (isset($xml->entry) || ($xml->getName() == 'feed' && isset($xml->children('http://www.w3.org/2005/Atom')->entry))) { + error_log("UrlNav: 检测到Atom格式"); + + // 获取所有entry元素(保持原样) + $entries = isset($xml->entry) ? $xml->entry : $xml->children('http://www.w3.org/2005/Atom')->entry; + + foreach ($entries as $entryIndex => $entry) { + $link = ''; + $title = ''; + $description = ''; + $fullContent = ''; + $pubDate = ''; + $guid = ''; + + // 获取链接(完全保持不变) + if (isset($entry->link)) { + foreach ($entry->link as $linkElem) { + $attributes = $linkElem->attributes(); + if ((string)$attributes['rel'] == 'alternate' || empty((string)$attributes['rel'])) { + $link = (string)$attributes['href']; + break; + } + } + } + + // 如果没有找到链接,使用id作为链接(完全保持不变) + if (empty($link) && isset($entry->id)) { + $link = (string)$entry->id; + } + + // 获取标题(完全保持不变) + if (isset($entry->title)) { + $title = (string)$entry->title; + } + + // 获取描述(summary)(完全保持不变) + if (isset($entry->summary)) { + $description = (string)$entry->summary; + } + + // ===== Atom全文抓取 ===== + // 1. 优先获取content元素(完全保持不变) + $atomContent = ''; + if (isset($entry->content)) { + $contentElem = $entry->content; + $attributes = $contentElem->attributes(); + + // 检查type属性 + $type = (string)($attributes['type'] ?? ''); + + if ($type === 'html' || $type === 'xhtml' || empty($type)) { + $atomContent = (string)$contentElem; + $fullContent = $atomContent; + error_log("UrlNav: 找到Atom content完整内容,类型: {$type},长度: " . strlen($fullContent)); + } elseif ($type === 'text') { + $atomContent = htmlspecialchars((string)$contentElem); + $fullContent = $atomContent; + error_log("UrlNav: 找到Atom text内容,长度: " . strlen($fullContent)); + } + } + + // 2. 如果没有content,尝试summary(完全保持不变) + if (empty($fullContent) && isset($entry->summary)) { + $fullContent = $description; + error_log("UrlNav: 使用Atom summary作为内容,长度: " . strlen($fullContent)); + } + + // 3. 检查是否有CDATA包裹(完全保持不变) + if (!empty($fullContent) && strpos($fullContent, '/s', $fullContent, $matches)) { + $fullContent = $matches[1]; + error_log("UrlNav: 从CDATA提取Atom内容"); + } + } + + // ===== Atom格式的页面抓取判断(完全保持不变) ===== + $pageContent = null; + $atomContentLength = strlen($fullContent); + + // 判断逻辑:只有在白名单中且未超过限制才抓取 + if ($isInWhitelist && $fullTextCount < $fullTextPerSite) { + $needPageFetch = true; + $fullTextCount++; + error_log("UrlNav Atom: 白名单抓取全文 #{$fullTextCount}/{$fullTextPerSite} - {$title}"); + } else { + $needPageFetch = false; + error_log("UrlNav Atom: " . ($isInWhitelist ? "已达限制" : "非白名单") . ",使用Atom内容({$atomContentLength}字符)"); + } + + // 执行Atom页面抓取(仅白名单) + if ($needPageFetch && !empty($link)) { + if ($entryIndex > 0) { + usleep(rand(300000, 800000)); + } + + $pageContent = self::fetchFullContentWithSelector($link, $selector, $pageFetchTimeout); + + if (!empty($pageContent) && strlen($pageContent) > $atomContentLength + 300) { + $fullContent = $pageContent; + error_log("UrlNav: ✓ Atom页面抓取成功"); + } + } + // ===== Atom页面抓取结束 ===== + + // 🔴 修改:Atom格式的非白名单网站全文字段处理 + if (!$isInWhitelist) { + // 非白名单网站,判断 summary 或 content 是否大于500字 + $descriptionLength = strlen($description); + $atomContentLength = strlen($atomContent); + + // 只要 summary 或 content 任意一个大于500字就存入全文 + if ($descriptionLength >= 500 || $atomContentLength >= 500) { + // 有足够长的内容,存入全文字段 + // 内容截断 + if (strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + error_log("UrlNav: Atom内容过长,已截断"); + } + error_log("UrlNav Atom: 非白名单网站,summary({$descriptionLength})或content({$atomContentLength})长度≥500字,存入全文字段"); + } else { + // 内容太短,留空不存储 + $fullContent = ''; + error_log("UrlNav Atom: 非白名单网站,summary({$descriptionLength})和content({$atomContentLength})都小于500字,全文字段留空"); + } + } else { + // 白名单网站保持原有逻辑 + // 4. 内容截断(完全保持不变) + if (!empty($fullContent) && strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + error_log("UrlNav: Atom内容过长,已截断"); + } + } + // ===== Atom全文抓取结束 ===== + + // 获取发布时间(updated或published)(完全保持不变) + if (isset($entry->updated)) { + $pubDate = date('Y-m-d H:i:s', strtotime((string)$entry->updated)); + } elseif (isset($entry->published)) { + $pubDate = date('Y-m-d H:i:s', strtotime((string)$entry->published)); + } else { + $pubDate = date('Y-m-d H:i:s'); + } + + // 获取guid(id)(完全保持不变) + if (isset($entry->id)) { + $guid = (string)$entry->id; + } else { + $guid = md5($link . $pubDate); + } + + $feeds[] = array( + 'title' => $title, + 'link' => $link, + 'description' => $description, + 'full_content' => $fullContent, // 🔴 现在非白名单网站可能为空 + 'pubDate' => $pubDate, + 'guid' => $guid + ); + } + } + // ========== 其他RSS格式解析(保持原样但应用相同逻辑修改) ========== + elseif (isset($xml->item)) { + error_log("UrlNav: 检测到RSS格式 (直接item)"); + foreach ($xml->item as $itemIndex => $item) { + // 优先获取完整内容 + $fullContent = ''; + $description = isset($item->description) ? (string)$item->description : ''; + $articleTitle = (string)$item->title; + $articleLink = (string)$item->link; + + // 尝试获取content:encoded(完整内容) + $encodedContent = ''; + $namespaces = $item->getNamespaces(true); + if (isset($namespaces['content'])) { + $contentNs = $item->children($namespaces['content']); + if (isset($contentNs->encoded)) { + $encodedContent = (string)$contentNs->encoded; + $fullContent = $encodedContent; + error_log("UrlNav: 找到content:encoded完整内容"); + } + } + + // 如果没找到content:encoded,使用description + if (empty($fullContent) && !empty($description)) { + $fullContent = $description; + error_log("UrlNav: 使用description作为内容"); + } + + // ===== 其他格式的页面抓取判断 ===== + $pageContent = null; + $rssContentLength = strlen($fullContent); + + // 判断逻辑:只有在白名单中且未超过限制才抓取 + if ($isInWhitelist && $fullTextCount < $fullTextPerSite) { + $needPageFetch = true; + $fullTextCount++; + error_log("UrlNav Other: 白名单抓取全文 #{$fullTextCount}/{$fullTextPerSite} - {$articleTitle}"); + } else { + $needPageFetch = false; + } + + // 页面抓取(仅白名单) + if ($needPageFetch && !empty($articleLink)) { + if ($itemIndex > 0) { + usleep(rand(300000, 800000)); + } + + $pageContent = self::fetchFullContentWithSelector($articleLink, $selector, $pageFetchTimeout); + + if (!empty($pageContent) && strlen($pageContent) > strlen($fullContent) + 300) { + $fullContent = $pageContent; + } + } + // ===== 其他格式页面抓取结束 ===== + + // 🔴 修改:其他格式的非白名单网站全文字段处理 + if (!$isInWhitelist) { + // 非白名单网站,判断 description 或 content:encoded 是否大于500字 + $descriptionLength = strlen($description); + $encodedContentLength = strlen($encodedContent); + + // 只要 description 或 content:encoded 任意一个大于500字就存入全文 + if ($descriptionLength >= 500 || $encodedContentLength >= 500) { + // 有足够长的内容,存入全文字段 + // 内容截断 + if (!empty($fullContent) && strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + } + error_log("UrlNav Other: 非白名单网站,description({$descriptionLength})或content:encoded({$encodedContentLength})长度≥500字,存入全文字段"); + } else { + // 内容太短,留空不存储 + $fullContent = ''; + error_log("UrlNav Other: 非白名单网站,description({$descriptionLength})和content:encoded({$encodedContentLength})都小于500字,全文字段留空"); + } + } else { + // 白名单网站保持原有逻辑 + // 内容截断 + if (!empty($fullContent) && strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + } + } + + $feeds[] = array( + 'title' => $articleTitle, + 'link' => $articleLink, + 'description' => $description, + 'full_content' => $fullContent, // 🔴 现在非白名单网站可能为空 + 'pubDate' => date('Y-m-d H:i:s', strtotime((string)$item->pubDate)), + 'guid' => (string)$item->guid + ); + } + } + // ========== 尝试检测命名空间(保持原样但应用相同逻辑修改) ========== + else { + // 检查是否有Atom命名空间 + $namespaces = $xml->getNamespaces(true); + foreach ($namespaces as $ns) { + if (strpos($ns, 'www.w3.org/2005/Atom') !== false) { + $atom = $xml->children($ns); + if (isset($atom->entry)) { + error_log("UrlNav: 检测到Atom命名空间格式"); + foreach ($atom->entry as $entryIndex => $entry) { + $entry = $entry->children($ns); + + // 获取完整内容 + $fullContent = ''; + $atomContent = ''; + $entryDescription = ''; + + if (isset($entry->content)) { + $atomContent = (string)$entry->content; + $fullContent = $atomContent; + } + + if (isset($entry->summary)) { + $entryDescription = (string)$entry->summary; + if (empty($fullContent)) { + $fullContent = $entryDescription; + } + } + + // ===== 命名空间格式的页面抓取判断 ===== + $needPageFetch = false; + $entryLink = isset($entry->link) ? (string)$entry->link : ''; + $rssContentLength = strlen($fullContent); + + // 判断逻辑:只有在白名单中且未超过限制才抓取 + if ($isInWhitelist && $fullTextCount < $fullTextPerSite) { + $needPageFetch = true; + $fullTextCount++; + } + + // 页面抓取(仅白名单) + if ($needPageFetch && !empty($entryLink)) { + if ($entryIndex > 0) { + usleep(rand(300000, 800000)); + } + + $pageContent = self::fetchFullContentWithSelector($entryLink, $selector, $pageFetchTimeout); + + if (!empty($pageContent)) { + $fullContent = $pageContent; + } + } + // ===== 命名空间格式页面抓取结束 ===== + + // 🔴 修改:命名空间格式的非白名单网站全文字段处理 + if (!$isInWhitelist) { + // 非白名单网站,判断 summary 或 content 是否大于500字 + $descriptionLength = strlen($entryDescription); + $contentLength = strlen($atomContent); + + // 只要 summary 或 content 任意一个大于500字就存入全文 + if ($descriptionLength >= 500 || $contentLength >= 500) { + // 有足够长的内容,存入全文字段 + // 内容截断 + if (!empty($fullContent) && strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + } + error_log("UrlNav Namespace: 非白名单网站,summary({$descriptionLength})或content({$contentLength})长度≥500字,存入全文字段"); + } else { + // 内容太短,留空不存储 + $fullContent = ''; + error_log("UrlNav Namespace: 非白名单网站,summary({$descriptionLength})和content({$contentLength})都小于500字,全文字段留空"); + } + } else { + // 白名单网站保持原有逻辑 + // 内容截断 + if (!empty($fullContent) && strlen($fullContent) > 10000) { + $fullContent = substr($fullContent, 0, 10000) . '... [内容已截断]'; + } + } + + $feeds[] = array( + 'title' => isset($entry->title) ? (string)$entry->title : '', + 'link' => $entryLink, + 'description' => $entryDescription, + 'full_content' => $fullContent, // 🔴 现在非白名单网站可能为空 + 'pubDate' => isset($entry->updated) ? date('Y-m-d H:i:s', strtotime((string)$entry->updated)) : date('Y-m-d H:i:s'), + 'guid' => isset($entry->id) ? (string)$entry->id : '' + ); + } + break; + } + } + } + + if (empty($feeds)) { + error_log("UrlNav: 无法识别的RSS格式"); + throw new Exception('无法识别的RSS格式'); + } + } + + if (empty($feeds)) { + error_log("UrlNav: RSS中没有找到文章内容"); + throw new Exception('RSS中没有找到文章内容'); + } + + error_log("UrlNav: 找到 " . count($feeds) . " 篇文章"); + error_log("UrlNav: <<< RSS解析成功"); + + return $feeds; + + } catch (Exception $e) { + error_log("UrlNav: <<< RSS解析失败: " . $e->getMessage()); + throw new Exception("解析RSS失败 [{$rssUrl}]: " . $e->getMessage()); + } +} + +/** + * 提取CDATA内容(处理多层或不规范CDATA) + * @param string $content 原始内容 + * @param string $source 来源标识(用于日志) + * @return string 处理后的内容 + */ +private static function extractCdataContent($content, $source = '') +{ + if (empty($content)) { + return $content; + } + + // 如果内容包含CDATA标记 + if (strpos($content, '/s', $content, $matches)) { + $extracted = $matches[1]; + + // 如果提取的内容明显比原来短,说明CDATA格式正确 + if (strlen($extracted) < strlen($content) * 0.9 && strlen($extracted) > 50) { + $content = $extracted; + error_log("UrlNav: 从CDATA提取 {$source} 内容 (第{$cdataCount}次)"); + } else { + // CDATA可能嵌套或不规范,尝试移除CDATA标记 + $content = str_replace('', '', $content); + error_log("UrlNav: 清理不规范的CDATA标记"); + break; + } + } else { + // CDATA格式不正确,直接移除标记 + $content = str_replace('', '', $content); + error_log("UrlNav: 清理不规范的CDATA标记"); + break; + } + } + + $finalLength = strlen($content); + if ($originalLength != $finalLength) { + error_log("UrlNav: CDATA处理完成 {$source},从 {$originalLength} 到 {$finalLength} 字符"); + } + } + + return $content; +} + + + /** + * 从文章页面抓取完整内容 + * @param string $articleUrl 文章链接 + * @param string $title 文章标题(用于日志) + * @param int $timeout 超时时间(秒) + * @return string|null 抓取到的内容,失败返回null + */ + private static function fetchFullContentFromPage($articleUrl, $title = '', $timeout = 10) + { + error_log("UrlNav: 尝试从页面抓取完整内容: {$articleUrl}"); + + try { + // 设置请求头,模拟浏览器 + $context = stream_context_create([ + 'http' => [ + 'timeout' => $timeout, + 'ignore_errors' => true, + 'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n" . + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8\r\n" . + "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n" . + "Accept-Encoding: gzip\r\n" . + "Connection: close\r\n" . + "Upgrade-Insecure-Requests: 1", + 'method' => 'GET' + ], + 'ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ] + ]); + + $html = @file_get_contents($articleUrl, false, $context); + + if ($html === false) { + $error = error_get_last(); + error_log("UrlNav: 无法访问文章页面: " . ($error['message'] ?? '未知错误')); + return null; + } + + if (empty($html)) { + error_log("UrlNav: 文章页面内容为空"); + return null; + } + + $htmlLength = strlen($html); + error_log("UrlNav: 获取页面成功,长度: {$htmlLength} 字节"); + + // 转换编码为UTF-8(如果检测到其他编码) + $encoding = 'UTF-8'; + if (preg_match('/]*charset=["\']?([a-zA-Z0-9\-_]+)["\']?/i', $html, $matches)) { + $encoding = strtoupper($matches[1]); + if ($encoding !== 'UTF-8') { + $html = mb_convert_encoding($html, 'UTF-8', $encoding); + error_log("UrlNav: 检测到编码 {$encoding},已转换为UTF-8"); + } + } + + // 提取内容 + $fullContent = ''; + + // 方法1:尝试提取Open Graph描述 + if (preg_match('/]*>(.*?)<\/article>/is', + '/]*>(.*?)<\/div>/is', + '/]*>(.*?)<\/div>/is', + // 通用内容区域 + '/]*>(.*?)<\/div>/is', + '/]*>(.*?)<\/div>/is', + // Typecho主题 + '/]*>(.*?)<\/div>/is', + '/]*>(.*?)<\/div>/is', + // 其他常见模式 + '/]*>(.*?)<\/div>/is', + '/]*>(.*?)<\/div>/is', + '/]*>(.*?)<\/div>/is' + ]; + + foreach ($contentPatterns as $pattern) { + if (preg_match($pattern, $html, $matches) && isset($matches[1])) { + $extracted = $matches[1]; + + // 移除脚本和样式 + $extracted = preg_replace('/]*>.*?<\/script>/is', '', $extracted); + $extracted = preg_replace('/]*>.*?<\/style>/is', '', $extracted); + $extracted = preg_replace('//s', '', $extracted); + + // 提取纯文本,但保留段落结构 + $extracted = strip_tags($extracted, '