prefix . 'peptide_news_clicks'; $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). 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'] : '' ) ), ); $result = $wpdb->insert( $table, $data ); // Update daily aggregate. if ( false !== $result ) { self::update_daily_stats( $article_id, $ip ); } return false !== $result; } /** * Update daily aggregated stats. * * @param int $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' ); $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 ) ); // 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 ) ); if ( $existing ) { $wpdb->update( $table, 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, ) ); } } /** * Get top articles by click count within a date range. * * @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). * * @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 */ 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] ); if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } } } return null; } /** * Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6). * * @param string $ip * @return string */ private static function anonymize_ip( string $ip ): string { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { return preg_replace( '/\.\d+$/', '.0', $ip ); } if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000'; } return '0.0.0.0'; } /** * Simple device detection from user agent. * * @param string $ua * @return string */ private static function detect_device( string $ua ): string { $ua = strtolower( $ua ); if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { return 'tablet'; } if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) { return 'mobile'; } if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) { return 'bot'; } return 'desktop'; } }