From 159a36eeb7368e5c2819b5ef1dd53167670d0f0f Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 22:42:06 +0700 Subject: [PATCH] refactor(analytics): split 365-line analytics into recording + reports Extract 6 read-only reporting queries (top articles, click trends, popular topics, device breakdown, source performance, CSV export) into Peptide_News_Analytics_Reports (196 lines). Main class retains click recording, daily stats aggregation, IP handling, and device detection (186 lines). Backward-compatible proxies preserved. Co-Authored-By: Claude Opus 4.6 --- .../class-peptide-news-analytics-reports.php | 196 +++++++++++ includes/class-peptide-news-analytics.php | 333 ++++-------------- includes/class-peptide-news.php | 1 + 3 files changed, 274 insertions(+), 256 deletions(-) create mode 100644 includes/class-peptide-news-analytics-reports.php diff --git a/includes/class-peptide-news-analytics-reports.php b/includes/class-peptide-news-analytics-reports.php new file mode 100644 index 0000000..969661d --- /dev/null +++ b/includes/class-peptide-news-analytics-reports.php @@ -0,0 +1,196 @@ +prefix . 'peptide_news_daily_stats'; + $articles_table = $wpdb->prefix . 'peptide_news_articles'; + + return $wpdb->get_results( $wpdb->prepare( + "SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at, + SUM(s.click_count) AS total_clicks, + SUM(s.unique_visitors) AS total_unique + FROM {$stats_table} s + INNER JOIN {$articles_table} a ON a.id = s.article_id + WHERE s.stat_date BETWEEN %s AND %s + GROUP BY a.id + ORDER BY total_clicks DESC + LIMIT %d", + $start_date, $end_date, $limit + ) ); + } + + /** + * Get click trends over time (daily totals). + * + * @param string $start_date Y-m-d. + * @param string $end_date Y-m-d. + * @return array + */ + public static function get_click_trends( string $start_date, string $end_date ): array { + global $wpdb; + + $table = $wpdb->prefix . 'peptide_news_daily_stats'; + + return $wpdb->get_results( $wpdb->prepare( + "SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique + FROM {$table} + WHERE stat_date BETWEEN %s AND %s + GROUP BY stat_date + ORDER BY stat_date ASC", + $start_date, $end_date + ) ); + } + + /** + * Get popular topics/categories by click volume. + * + * Aggregates clicks across articles sharing the same category. + * + * @param string $start_date Y-m-d. + * @param string $end_date Y-m-d. + * @param int $limit Max topics. + * @return array + */ + public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array { + global $wpdb; + + $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; + $articles_table = $wpdb->prefix . 'peptide_news_articles'; + + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT a.categories, a.title, SUM(s.click_count) AS clicks + FROM {$stats_table} s + INNER JOIN {$articles_table} a ON a.id = s.article_id + WHERE s.stat_date BETWEEN %s AND %s AND a.categories != '' + GROUP BY a.id + ORDER BY clicks DESC", + $start_date, $end_date + ) ); + + $topics = array(); + foreach ( $rows as $row ) { + $cats = array_map( 'trim', explode( ',', $row->categories ) ); + foreach ( $cats as $cat ) { + if ( empty( $cat ) ) { + continue; + } + $key = strtolower( $cat ); + if ( ! isset( $topics[ $key ] ) ) { + $topics[ $key ] = array( 'topic' => $cat, 'total_clicks' => 0, 'article_count' => 0 ); + } + $topics[ $key ]['total_clicks'] += (int) $row->clicks; + $topics[ $key ]['article_count'] += 1; + } + } + + usort( $topics, function ( $a, $b ) { + return $b['total_clicks'] - $a['total_clicks']; + } ); + + return array_slice( $topics, 0, $limit ); + } + + /** + * Get device breakdown stats. + * + * @param string $start_date Y-m-d. + * @param string $end_date Y-m-d. + * @return array + */ + public static function get_device_breakdown( string $start_date, string $end_date ): array { + global $wpdb; + + $table = $wpdb->prefix . 'peptide_news_clicks'; + + return $wpdb->get_results( $wpdb->prepare( + "SELECT device_type, COUNT(*) AS click_count + FROM {$table} + WHERE DATE(clicked_at) BETWEEN %s AND %s + GROUP BY device_type + ORDER BY click_count DESC", + $start_date, $end_date + ) ); + } + + /** + * Get source performance stats. + * + * @param string $start_date Y-m-d. + * @param string $end_date Y-m-d. + * @return array + */ + public static function get_source_performance( string $start_date, string $end_date ): array { + global $wpdb; + + $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; + $articles_table = $wpdb->prefix . 'peptide_news_articles'; + + return $wpdb->get_results( $wpdb->prepare( + "SELECT a.source, + COUNT(DISTINCT a.id) AS article_count, + SUM(s.click_count) AS total_clicks, + ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article + FROM {$stats_table} s + INNER JOIN {$articles_table} a ON a.id = s.article_id + WHERE s.stat_date BETWEEN %s AND %s + GROUP BY a.source + ORDER BY total_clicks DESC", + $start_date, $end_date + ) ); + } + + /** + * Export click data as CSV-ready array. + * + * Capped at CSV_EXPORT_MAX_ROWS at the SQL level. + * + * @param string $start_date Y-m-d. + * @param string $end_date Y-m-d. + * @return array Associative arrays (one per click row). + */ + public static function export_clicks_csv( string $start_date, string $end_date ): array { + global $wpdb; + + $clicks_table = $wpdb->prefix . 'peptide_news_clicks'; + $articles_table = $wpdb->prefix . 'peptide_news_articles'; + + return $wpdb->get_results( $wpdb->prepare( + "SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, + c.referrer_url, c.page_url, c.device_type, c.session_id + FROM {$clicks_table} c + INNER JOIN {$articles_table} a ON a.id = c.article_id + WHERE DATE(c.clicked_at) BETWEEN %s AND %s + ORDER BY c.clicked_at DESC + LIMIT %d", + $start_date, $end_date, self::CSV_EXPORT_MAX_ROWS + ), ARRAY_A ); + } +} diff --git a/includes/class-peptide-news-analytics.php b/includes/class-peptide-news-analytics.php index a01e7fe..0740927 100755 --- a/includes/class-peptide-news-analytics.php +++ b/includes/class-peptide-news-analytics.php @@ -1,54 +1,59 @@ prefix . 'peptide_news_clicks'; + $ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip(); - $ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip(); - - // Skip recording if no valid IP is available. if ( null === $ip ) { return false; } - // Optionally anonymize IP (GDPR-friendly). + // GDPR-friendly IP anonymization. if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) { $ip = self::anonymize_ip( $ip ); } $data = array( - 'article_id' => absint( $article_id ), - 'clicked_at' => current_time( 'mysql' ), - 'user_ip' => sanitize_text_field( $ip ), - 'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ), - 'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ), - 'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ), - 'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ), - 'user_id' => is_user_logged_in() ? get_current_user_id() : null, - 'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ), + 'article_id' => absint( $article_id ), + 'clicked_at' => current_time( 'mysql' ), + 'user_ip' => sanitize_text_field( $ip ), + 'user_agent' => sanitize_text_field( $meta['user_agent'] ?? '' ), + 'referrer_url' => esc_url_raw( $meta['referrer'] ?? '' ), + 'page_url' => esc_url_raw( $meta['page_url'] ?? '' ), + 'session_id' => sanitize_text_field( $meta['session_id'] ?? '' ), + 'user_id' => is_user_logged_in() ? get_current_user_id() : null, + 'device_type' => sanitize_text_field( self::detect_device( $meta['user_agent'] ?? '' ) ), ); $result = $wpdb->insert( $table, $data ); - // Update daily aggregate. if ( false !== $result ) { self::update_daily_stats( $article_id, $ip ); } @@ -57,265 +62,59 @@ class Peptide_News_Analytics { } /** - * Update daily aggregated stats. + * Upsert daily aggregated stats for an article. * - * @param int $article_id - * @param string $ip Anonymized IP for unique visitor counting. + * @param int $article_id Article ID. + * @param string $ip Anonymized IP for unique visitor counting. */ private static function update_daily_stats( int $article_id, string $ip ): void { global $wpdb; - $table = $wpdb->prefix . 'peptide_news_daily_stats'; - $today = current_time( 'Y-m-d' ); + $table = $wpdb->prefix . 'peptide_news_daily_stats'; + $today = current_time( 'Y-m-d' ); $clicks_tbl = $wpdb->prefix . 'peptide_news_clicks'; - // Upsert daily click count. $existing = $wpdb->get_row( $wpdb->prepare( "SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s", - $article_id, - $today + $article_id, $today ) ); - // Count unique IPs for today. $unique = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s", - $article_id, - $today + $article_id, $today ) ); if ( $existing ) { $wpdb->update( $table, - array( - 'click_count' => $existing->click_count + 1, - 'unique_visitors' => (int) $unique, - ), + array( 'click_count' => $existing->click_count + 1, 'unique_visitors' => (int) $unique ), array( 'id' => $existing->id ), array( '%d', '%d' ), array( '%d' ) ); } else { - $wpdb->insert( - $table, - array( - 'article_id' => $article_id, - 'stat_date' => $today, - 'click_count' => 1, - 'unique_visitors' => 1, - ) - ); + $wpdb->insert( $table, array( + 'article_id' => $article_id, + 'stat_date' => $today, + 'click_count' => 1, + 'unique_visitors' => 1, + ) ); } } /** - * Get top articles by click count within a date range. + * Get the client IP address from server headers. * - * @param string $start_date Y-m-d - * @param string $end_date Y-m-d - * @param int $limit - * @return array - */ - public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array { - global $wpdb; - - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at, - SUM(s.click_count) AS total_clicks, - SUM(s.unique_visitors) AS total_unique - FROM {$stats_table} s - INNER JOIN {$articles_table} a ON a.id = s.article_id - WHERE s.stat_date BETWEEN %s AND %s - GROUP BY a.id - ORDER BY total_clicks DESC - LIMIT %d", - $start_date, - $end_date, - $limit - ) ); - } - - /** - * Get click trends over time (daily totals). + * Checks CF-Connecting-IP, X-Forwarded-For, X-Real-IP, then REMOTE_ADDR. * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_click_trends( string $start_date, string $end_date ): array { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_daily_stats'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique - FROM {$table} - WHERE stat_date BETWEEN %s AND %s - GROUP BY stat_date - ORDER BY stat_date ASC", - $start_date, - $end_date - ) ); - } - - /** - * Get popular topics/categories by click volume. - * - * @param string $start_date - * @param string $end_date - * @param int $limit - * @return array - */ - public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array { - global $wpdb; - - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - // Get articles with their click counts. - $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT a.categories, a.title, SUM(s.click_count) AS clicks - FROM {$stats_table} s - INNER JOIN {$articles_table} a ON a.id = s.article_id - WHERE s.stat_date BETWEEN %s AND %s AND a.categories != '' - GROUP BY a.id - ORDER BY clicks DESC", - $start_date, - $end_date - ) ); - - // Aggregate by category. - $topics = array(); - foreach ( $rows as $row ) { - $cats = array_map( 'trim', explode( ',', $row->categories ) ); - foreach ( $cats as $cat ) { - if ( empty( $cat ) ) { - continue; - } - $cat_lower = strtolower( $cat ); - if ( ! isset( $topics[ $cat_lower ] ) ) { - $topics[ $cat_lower ] = array( - 'topic' => $cat, - 'total_clicks' => 0, - 'article_count' => 0, - ); - } - $topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks; - $topics[ $cat_lower ]['article_count'] += 1; - } - } - - // Sort by clicks. - usort( $topics, function ( $a, $b ) { - return $b['total_clicks'] - $a['total_clicks']; - } ); - - return array_slice( $topics, 0, $limit ); - } - - /** - * Get device breakdown stats. - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_device_breakdown( string $start_date, string $end_date ): array { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_clicks'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT device_type, COUNT(*) AS click_count - FROM {$table} - WHERE DATE(clicked_at) BETWEEN %s AND %s - GROUP BY device_type - ORDER BY click_count DESC", - $start_date, - $end_date - ) ); - } - - /** - * Get source performance stats. - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_source_performance( string $start_date, string $end_date ): array { - global $wpdb; - - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT a.source, - COUNT(DISTINCT a.id) AS article_count, - SUM(s.click_count) AS total_clicks, - ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article - FROM {$stats_table} s - INNER JOIN {$articles_table} a ON a.id = s.article_id - WHERE s.stat_date BETWEEN %s AND %s - GROUP BY a.source - ORDER BY total_clicks DESC", - $start_date, - $end_date - ) ); - } - - /** - * Hard cap on CSV export rows to prevent memory exhaustion on large datasets. - * - * @var int - */ - const CSV_EXPORT_MAX_ROWS = 50000; - - /** - * Export click data as CSV-ready array. - * - * Capped at CSV_EXPORT_MAX_ROWS to prevent memory exhaustion on sites - * with large click volumes. The cap is applied at the SQL level so - * only the limited result set is ever loaded into PHP memory. - * - * @param string $start_date Y-m-d format. - * @param string $end_date Y-m-d format. - * @return array Array of associative arrays (one per click row). - */ - public static function export_clicks_csv( string $start_date, string $end_date ): array { - global $wpdb; - - $clicks_table = $wpdb->prefix . 'peptide_news_clicks'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; - - return $wpdb->get_results( $wpdb->prepare( - "SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, - c.referrer_url, c.page_url, c.device_type, c.session_id - FROM {$clicks_table} c - INNER JOIN {$articles_table} a ON a.id = c.article_id - WHERE DATE(c.clicked_at) BETWEEN %s AND %s - ORDER BY c.clicked_at DESC - LIMIT %d", - $start_date, - $end_date, - self::CSV_EXPORT_MAX_ROWS - ), ARRAY_A ); - } - - /** - * Get the client IP address. - * - * @return string|null + * @return string|null Valid IP or null. */ private static function get_client_ip(): ?string { $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); foreach ( $ip_keys as $key ) { if ( ! empty( $_SERVER[ $key ] ) ) { - $ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ); - $ip = trim( $ip[0] ); + $ip = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) )[0] ); if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } @@ -325,12 +124,7 @@ class Peptide_News_Analytics { return null; } - /** - * Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6). - * - * @param string $ip - * @return string - */ + /** Anonymize IP: zero last octet (IPv4) or last 80 bits (IPv6). */ private static function anonymize_ip( string $ip ): string { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return preg_replace( '/\.\d+$/', '.0', $ip ); @@ -341,12 +135,7 @@ class Peptide_News_Analytics { return '0.0.0.0'; } - /** - * Simple device detection from user agent. - * - * @param string $ua - * @return string - */ + /** Simple device detection from user agent string. */ private static function detect_device( string $ua ): string { $ua = strtolower( $ua ); @@ -362,4 +151,36 @@ class Peptide_News_Analytics { return 'desktop'; } + + // ── Backward-compatible proxies ───────────────────────────────────── + + /** @see Peptide_News_Analytics_Reports::get_top_articles() */ + public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array { + return Peptide_News_Analytics_Reports::get_top_articles( $start_date, $end_date, $limit ); + } + + /** @see Peptide_News_Analytics_Reports::get_click_trends() */ + public static function get_click_trends( string $start_date, string $end_date ): array { + return Peptide_News_Analytics_Reports::get_click_trends( $start_date, $end_date ); + } + + /** @see Peptide_News_Analytics_Reports::get_popular_topics() */ + public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array { + return Peptide_News_Analytics_Reports::get_popular_topics( $start_date, $end_date, $limit ); + } + + /** @see Peptide_News_Analytics_Reports::get_device_breakdown() */ + public static function get_device_breakdown( string $start_date, string $end_date ): array { + return Peptide_News_Analytics_Reports::get_device_breakdown( $start_date, $end_date ); + } + + /** @see Peptide_News_Analytics_Reports::get_source_performance() */ + public static function get_source_performance( string $start_date, string $end_date ): array { + return Peptide_News_Analytics_Reports::get_source_performance( $start_date, $end_date ); + } + + /** @see Peptide_News_Analytics_Reports::export_clicks_csv() */ + public static function export_clicks_csv( string $start_date, string $end_date ): array { + return Peptide_News_Analytics_Reports::export_clicks_csv( $start_date, $end_date ); + } } diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 9562719..184f7e1 100755 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -40,6 +40,7 @@ class Peptide_News { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-source-resolver.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rss-source.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics-reports.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-articles.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';