diff --git a/admin/class-pn-admin-field-renderers.php b/admin/class-pn-admin-field-renderers.php index 87a852c..84d4b43 100644 --- a/admin/class-pn-admin-field-renderers.php +++ b/admin/class-pn-admin-field-renderers.php @@ -144,91 +144,15 @@ class Peptide_News_Admin_Field_Renderers { ); } - public static function render_filter_enabled_field(): void { - $value = get_option( 'peptide_news_filter_enabled', 1 ); - printf( - '', - checked( $value, 1, false ), - esc_html__( 'Filter out ads, press releases, and promotional content during fetch', 'peptide-news' ) - ); - } + // ── Filter field proxies — @see Peptide_News_Admin_Filter_Field_Renderers ── - public static function render_filter_sensitivity_field(): void { - $value = get_option( 'peptide_news_filter_sensitivity', 'moderate' ); - $options = array( - 'strict' => __( 'Strict — block aggressively (may catch some legitimate articles)', 'peptide-news' ), - 'moderate' => __( 'Moderate — balanced filtering (recommended)', 'peptide-news' ), - 'lenient' => __( 'Lenient — only block obvious promotional content', 'peptide-news' ), - ); - - echo ''; - } - - public static function render_filter_llm_enabled_field(): void { - $value = get_option( 'peptide_news_filter_llm_enabled', 0 ); - printf( - '', - checked( $value, 1, false ), - esc_html__( 'Use LLM to classify borderline articles (requires AI Analysis to be enabled)', 'peptide-news' ) - ); - } - - public static function render_filter_llm_model_field(): void { - $value = get_option( 'peptide_news_filter_llm_model', '' ); - printf( - '', - esc_attr( $value ) - ); - echo '
' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '
'; - } - - public static function render_filter_title_keywords_field(): void { - $value = get_option( 'peptide_news_filter_title_keywords', '' ); - $placeholder = class_exists( 'Peptide_News_Content_Filter' ) - ? Peptide_News_Content_Filter::get_default_title_keywords_text() - : ''; - printf( - '', - esc_attr( $placeholder ), - esc_textarea( $value ) - ); - echo '' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; - } - - public static function render_filter_body_keywords_field(): void { - $value = get_option( 'peptide_news_filter_body_keywords', '' ); - $placeholder = class_exists( 'Peptide_News_Content_Filter' ) - ? Peptide_News_Content_Filter::get_default_body_keywords_text() - : ''; - printf( - '', - esc_attr( $placeholder ), - esc_textarea( $value ) - ); - echo '' . esc_html__( 'One keyword/phrase per line. Multiple matches in article body trigger filtering (threshold depends on sensitivity). Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; - } - - public static function render_filter_blocked_domains_field(): void { - $value = get_option( 'peptide_news_filter_blocked_domains', '' ); - $placeholder = class_exists( 'Peptide_News_Content_Filter' ) - ? Peptide_News_Content_Filter::get_default_blocked_domains_text() - : ''; - printf( - '', - esc_attr( $placeholder ), - esc_textarea( $value ) - ); - echo '' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; - } + public static function render_filter_enabled_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_enabled_field(); } + public static function render_filter_sensitivity_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_sensitivity_field(); } + public static function render_filter_llm_enabled_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_llm_enabled_field(); } + public static function render_filter_llm_model_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_llm_model_field(); } + public static function render_filter_title_keywords_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_title_keywords_field(); } + public static function render_filter_body_keywords_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_body_keywords_field(); } + public static function render_filter_blocked_domains_field(): void { Peptide_News_Admin_Filter_Field_Renderers::render_filter_blocked_domains_field(); } public static function render_llm_enabled_field(): void { $value = get_option( 'peptide_news_llm_enabled', 0 ); diff --git a/admin/class-pn-admin-filter-field-renderers.php b/admin/class-pn-admin-filter-field-renderers.php new file mode 100644 index 0000000..b6003dc --- /dev/null +++ b/admin/class-pn-admin-filter-field-renderers.php @@ -0,0 +1,103 @@ + %s', + checked( $value, 1, false ), + esc_html__( 'Filter out ads, press releases, and promotional content during fetch', 'peptide-news' ) + ); + } + + public static function render_filter_sensitivity_field(): void { + $value = get_option( 'peptide_news_filter_sensitivity', 'moderate' ); + $options = array( + 'strict' => __( 'Strict — block aggressively (may catch some legitimate articles)', 'peptide-news' ), + 'moderate' => __( 'Moderate — balanced filtering (recommended)', 'peptide-news' ), + 'lenient' => __( 'Lenient — only block obvious promotional content', 'peptide-news' ), + ); + + echo ''; + } + + public static function render_filter_llm_enabled_field(): void { + $value = get_option( 'peptide_news_filter_llm_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Use LLM to classify borderline articles (requires AI Analysis to be enabled)', 'peptide-news' ) + ); + } + + public static function render_filter_llm_model_field(): void { + $value = get_option( 'peptide_news_filter_llm_model', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '' . esc_html__( 'OpenRouter model for content classification. Leave blank to use the Keywords Model above.', 'peptide-news' ) . '
'; + } + + public static function render_filter_title_keywords_field(): void { + $value = get_option( 'peptide_news_filter_title_keywords', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_title_keywords_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '' . esc_html__( 'One keyword/phrase per line. Articles with these in the title are blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; + } + + public static function render_filter_body_keywords_field(): void { + $value = get_option( 'peptide_news_filter_body_keywords', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_body_keywords_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '' . esc_html__( 'One keyword/phrase per line. Multiple matches in article body trigger filtering (threshold depends on sensitivity). Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; + } + + public static function render_filter_blocked_domains_field(): void { + $value = get_option( 'peptide_news_filter_blocked_domains', '' ); + $placeholder = class_exists( 'Peptide_News_Content_Filter' ) + ? Peptide_News_Content_Filter::get_default_blocked_domains_text() + : ''; + printf( + '', + esc_attr( $placeholder ), + esc_textarea( $value ) + ); + echo '' . esc_html__( 'One domain per line. Articles from these sources are always blocked. Leave empty to use built-in defaults.', 'peptide-news' ) . '
'; + } +} 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-rest-api.php b/includes/class-peptide-news-rest-api.php index f697f4b..80d4e18 100755 --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -1,44 +1,47 @@ articles_handler = new Peptide_News_Rest_Articles(); + } + + /** Register all REST routes. */ + public function register_routes(): void { // Public: list articles. register_rest_route( self::API_NAMESPACE, '/articles', array( 'methods' => 'GET', - 'callback' => array( $this, 'get_articles' ), + 'callback' => array( $this->articles_handler, 'get_articles' ), 'permission_callback' => '__return_true', 'args' => array( - 'count' => array( - 'default' => 10, - 'sanitize_callback' => 'absint', - ), - 'page' => array( - 'default' => 1, - 'sanitize_callback' => 'absint', - ), + 'count' => array( 'default' => 10, 'sanitize_callback' => 'absint' ), + 'page' => array( 'default' => 1, 'sanitize_callback' => 'absint' ), ), ) ); @@ -79,43 +82,28 @@ class Peptide_News_Rest_API { return true; } - /** - * Common date-range argument definitions. - * - * @return array - */ + /** Common date-range argument definitions for analytics endpoints. */ private function get_date_range_args(): array { return array( 'start_date' => array( - 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), - 'sanitize_callback' => 'sanitize_text_field', - 'validate_callback' => array( $this, 'validate_date_format' ), + 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => array( $this, 'validate_date_format' ), ), 'end_date' => array( - 'default' => gmdate( 'Y-m-d' ), - 'sanitize_callback' => 'sanitize_text_field', - 'validate_callback' => array( $this, 'validate_date_format' ), - ), - 'limit' => array( - 'default' => 20, - 'sanitize_callback' => 'absint', + 'default' => gmdate( 'Y-m-d' ), + 'sanitize_callback' => 'sanitize_text_field', + 'validate_callback' => array( $this, 'validate_date_format' ), ), + 'limit' => array( 'default' => 20, 'sanitize_callback' => 'absint' ), ); } - /** - * Validate that a parameter is a Y-m-d date string. - * - * @param string $value - * @param WP_REST_Request $request - * @param string $param - * @return true|WP_Error - */ + /** Validate that a parameter is a Y-m-d date string. */ public function validate_date_format( $value, $request, $param ) { if ( ! preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $value ) ) { return new WP_Error( 'rest_invalid_date', - /* translators: %s: parameter name */ sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ), array( 'status' => 400 ) ); @@ -123,255 +111,51 @@ class Peptide_News_Rest_API { return true; } - /** - * GET /articles - * - * Returns active articles with server-side transient caching. - * Cache is invalidated whenever new articles are stored. - */ - public function get_articles( $request ) { - global $wpdb; - - $count = absint( min( $request->get_param( 'count' ), 100 ) ); - $page = absint( max( $request->get_param( 'page' ), 1 ) ); - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Build cache key from page and count params. - $cache_key = 'peptide_news_articles_' . $page . '_' . $count; - $cached_response = get_transient( $cache_key ); - if ( false !== $cached_response ) { - return rest_ensure_response( $cached_response ); - } - - $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) ); - - // Ensure page doesn't exceed max available pages. - $max_page = max( 1, (int) ceil( $total / $count ) ); - $page = min( $page, $max_page ); - $offset = ( $page - 1 ) * $count; - - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, source, source_url, title, excerpt, ai_summary, author, - thumbnail_url, thumbnail_local, published_at, categories, tags - FROM {$table} - WHERE is_active = 1 - ORDER BY published_at DESC - LIMIT %d OFFSET %d", - $count, - $offset - ) ); - - // Strip source name from titles, excerpts, and summaries. - $nbsp = "\xC2\xA0"; // UTF-8 non-breaking space. - foreach ( $articles as $article ) { - $fields = array( 'title', 'excerpt', 'ai_summary' ); - foreach ( $fields as $field ) { - if ( empty( $article->$field ) ) { - continue; - } - // First pass: use strip_source_suffix for known patterns. - $article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url ); - - // Second pass: catch any remaining nbsp-separated publisher names - // (e.g., "...text\xC2\xA0\xC2\xA0Publisher Name" from Google News RSS). - $text = $article->$field; - $pos = strrpos( $text, $nbsp . $nbsp ); - if ( false === $pos ) { - $pos = strrpos( $text, $nbsp ); - } - if ( false !== $pos && $pos > 10 ) { - $candidate = rtrim( substr( $text, 0, $pos ) ); - if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) { - $article->$field = $candidate; - } - } - } - } - - $data = array( - 'articles' => $articles, - 'total' => (int) $total, - 'page' => $page, - 'per_page' => $count, - 'total_pages' => ceil( $total / $count ), - ); - - // Cache the response for 1 hour. - set_transient( $cache_key, $data, HOUR_IN_SECONDS ); - - $response = rest_ensure_response( $data ); - - return $response; - } - - /** - * Strip the source/publisher name from the end of a string. - * - * Handles common separator patterns like " - Source", " | Source", - * " — Source", and " – Source". Uses case-insensitive matching against - * the source field. Also extracts publisher names from source URLs - * and has a regex fallback for common RSS title patterns. - * - * @param string $text The text to clean. - * @param string $source The source name to strip. - * @param string $source_url The source URL (used to extract domain-based publisher names). - * @return string Cleaned text. - */ - private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string { - if ( empty( $text ) ) { - return $text; - } - - // Decode HTML entities first so becomes actual characters. - $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); - - // Common separators between title/excerpt and source name. - // Includes regular spaces, non-breaking spaces (\xC2\xA0), and typographic dashes. - $nbsp = "\xC2\xA0"; // UTF-8 non-breaking space - $separators = array( - ' - ', ' | ', ' — ', ' – ', ' // ', - $nbsp . $nbsp, // double non-breaking space (Google News RSS excerpts) - ' ', // double regular space - $nbsp, // single non-breaking space - ); - - // Build list of source names to try matching against. - $source_names = array(); - if ( ! empty( $source ) ) { - $source_names[] = $source; - } - - // Get cached source names list to avoid database queries on every API call. - $cached_sources = get_transient( 'peptide_news_source_names' ); - if ( false === $cached_sources ) { - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - $cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" ); - if ( ! is_array( $cached_sources ) ) { - $cached_sources = array(); - } - set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS ); - } - $source_names = array_merge( $source_names, $cached_sources ); - - // Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net"). - if ( ! empty( $source_url ) ) { - $host = wp_parse_url( $source_url, PHP_URL_HOST ); - if ( $host ) { - $host = preg_replace( '/^www\./', '', $host ); - $parts = explode( '.', $host ); - if ( count( $parts ) >= 2 ) { - $domain_name = $parts[ count( $parts ) - 2 ]; - $source_names[] = str_replace( '-', ' ', $domain_name ); - $source_names[] = $domain_name; - } - } - } - - // Try exact (case-insensitive) match against known source names. - foreach ( $source_names as $name ) { - foreach ( $separators as $sep ) { - $suffix = $sep . $name; - $suffix_len = strlen( $suffix ); - if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) { - return rtrim( substr( $text, 0, -$suffix_len ) ); - } - } - } - - // Fallback 1: strip trailing " - Short Publisher Name" patterns (1-5 words). - // Only strips if the part after the separator looks like a publisher name - // (starts with uppercase, no sentence-ending punctuation). - $pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u'; - $candidate = preg_replace( $pattern, '', $text ); - if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) { - return rtrim( $candidate ); - } - - // Fallback 2: strip trailing nbsp-separated publisher names. - // Google News RSS excerpts use double non-breaking space + publisher name - // when the source field doesn't match (e.g., source = "news.google.com"). - // Uses mb_ereg for reliable multibyte matching. - if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) { - if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) { - return rtrim( $m[1] ); - } - } - - return $text; - } - - /** - * GET /analytics/top - */ + /** GET /analytics/top */ public function get_top_articles( $request ) { - $data = Peptide_News_Analytics::get_top_articles( - $request->get_param( 'start_date' ), - $request->get_param( 'end_date' ), - $request->get_param( 'limit' ) - ); - return rest_ensure_response( $data ); + return rest_ensure_response( Peptide_News_Analytics::get_top_articles( + $request->get_param( 'start_date' ), $request->get_param( 'end_date' ), $request->get_param( 'limit' ) + ) ); } - /** - * GET /analytics/trends - */ + /** GET /analytics/trends */ public function get_trends( $request ) { - $data = Peptide_News_Analytics::get_click_trends( - $request->get_param( 'start_date' ), - $request->get_param( 'end_date' ) - ); - return rest_ensure_response( $data ); + return rest_ensure_response( Peptide_News_Analytics::get_click_trends( + $request->get_param( 'start_date' ), $request->get_param( 'end_date' ) + ) ); } - /** - * GET /analytics/topics - */ + /** GET /analytics/topics */ public function get_topics( $request ) { - $data = Peptide_News_Analytics::get_popular_topics( - $request->get_param( 'start_date' ), - $request->get_param( 'end_date' ), - $request->get_param( 'limit' ) - ); - return rest_ensure_response( $data ); + return rest_ensure_response( Peptide_News_Analytics::get_popular_topics( + $request->get_param( 'start_date' ), $request->get_param( 'end_date' ), $request->get_param( 'limit' ) + ) ); } - /** - * GET /analytics/devices - */ + /** GET /analytics/devices */ public function get_devices( $request ) { - $data = Peptide_News_Analytics::get_device_breakdown( - $request->get_param( 'start_date' ), - $request->get_param( 'end_date' ) - ); - return rest_ensure_response( $data ); + return rest_ensure_response( Peptide_News_Analytics::get_device_breakdown( + $request->get_param( 'start_date' ), $request->get_param( 'end_date' ) + ) ); } - /** - * GET /analytics/sources - */ + /** GET /analytics/sources */ public function get_sources( $request ) { - $data = Peptide_News_Analytics::get_source_performance( - $request->get_param( 'start_date' ), - $request->get_param( 'end_date' ) - ); - return rest_ensure_response( $data ); + return rest_ensure_response( Peptide_News_Analytics::get_source_performance( + $request->get_param( 'start_date' ), $request->get_param( 'end_date' ) + ) ); } - /** - * GET /analytics/export — returns CSV download. - */ + /** GET /analytics/export — returns CSV-ready data as JSON. */ public function get_export( $request ) { $data = Peptide_News_Analytics::export_clicks_csv( - $request->get_param( 'start_date' ), - $request->get_param( 'end_date' ) + $request->get_param( 'start_date' ), $request->get_param( 'end_date' ) ); if ( empty( $data ) ) { return rest_ensure_response( array( 'message' => 'No data for the selected period.' ) ); } - // Return as JSON (client can convert to CSV). return rest_ensure_response( array( 'headers' => array_keys( $data[0] ), 'rows' => $data, @@ -382,11 +166,7 @@ class Peptide_News_Rest_API { /** * AJAX handler: delete one or more articles by ID. * - * Expects POST with 'ids' (comma-separated article IDs) and 'nonce'. - * Cascading foreign keys on the clicks and daily_stats tables handle - * related analytics cleanup automatically. - * - * @since 2.1.0 + * Side effects: nonce/capability check, DB delete, cache clear, logger write. */ public static function ajax_delete_articles(): void { check_ajax_referer( 'peptide_news_admin', 'nonce' ); @@ -422,14 +202,9 @@ class Peptide_News_Rest_API { wp_send_json_error( 'Database error.' ); } - // Clear article cache so the frontend reflects changes. delete_transient( 'peptide_news_articles_1_10' ); - Peptide_News_Logger::info( 'Deleted ' . $deleted . ' article(s): IDs ' . implode( ', ', $ids ), 'admin' ); - wp_send_json_success( array( - 'deleted' => $deleted, - 'ids' => $ids, - ) ); + wp_send_json_success( array( 'deleted' => $deleted, 'ids' => $ids ) ); } } diff --git a/includes/class-peptide-news-rest-articles.php b/includes/class-peptide-news-rest-articles.php new file mode 100644 index 0000000..de12b17 --- /dev/null +++ b/includes/class-peptide-news-rest-articles.php @@ -0,0 +1,195 @@ +get_param( 'count' ), 100 ) ); + $page = absint( max( $request->get_param( 'page' ), 1 ) ); + $table = $wpdb->prefix . 'peptide_news_articles'; + + $cache_key = 'peptide_news_articles_' . $page . '_' . $count; + $cached_response = get_transient( $cache_key ); + if ( false !== $cached_response ) { + return rest_ensure_response( $cached_response ); + } + + $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) ); + $max_page = max( 1, (int) ceil( $total / $count ) ); + $page = min( $page, $max_page ); + $offset = ( $page - 1 ) * $count; + + $articles = $wpdb->get_results( $wpdb->prepare( + "SELECT id, source, source_url, title, excerpt, ai_summary, author, + thumbnail_url, thumbnail_local, published_at, categories, tags + FROM {$table} + WHERE is_active = 1 + ORDER BY published_at DESC + LIMIT %d OFFSET %d", + $count, + $offset + ) ); + + $this->clean_source_suffixes( $articles ); + + $data = array( + 'articles' => $articles, + 'total' => (int) $total, + 'page' => $page, + 'per_page' => $count, + 'total_pages' => ceil( $total / $count ), + ); + + set_transient( $cache_key, $data, HOUR_IN_SECONDS ); + + return rest_ensure_response( $data ); + } + + /** + * Strip source/publisher names from title, excerpt, and ai_summary fields. + * + * Mutates article objects in-place. + * + * @param array $articles Array of article objects from DB. + */ + private function clean_source_suffixes( array &$articles ): void { + $nbsp = "\xC2\xA0"; + + foreach ( $articles as $article ) { + foreach ( array( 'title', 'excerpt', 'ai_summary' ) as $field ) { + if ( empty( $article->$field ) ) { + continue; + } + $article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url ); + + // Catch remaining nbsp-separated publisher names from Google News RSS. + $text = $article->$field; + $pos = strrpos( $text, $nbsp . $nbsp ); + if ( false === $pos ) { + $pos = strrpos( $text, $nbsp ); + } + if ( false !== $pos && $pos > 10 ) { + $candidate = rtrim( substr( $text, 0, $pos ) ); + if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) { + $article->$field = $candidate; + } + } + } + } + } + + /** + * Strip the source/publisher name from the end of a string. + * + * Handles separators: " - Source", " | Source", " — Source", " – Source". + * Tries exact match against known source names, then regex fallback. + * + * @param string $text The text to clean. + * @param string $source The source name to strip. + * @param string $source_url The source URL (for domain-based publisher names). + * @return string Cleaned text. + */ + private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string { + if ( empty( $text ) ) { + return $text; + } + + $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + + $nbsp = "\xC2\xA0"; + $separators = array( ' - ', ' | ', ' — ', ' – ', ' // ', $nbsp . $nbsp, ' ', $nbsp ); + + $source_names = $this->collect_source_names( $source, $source_url ); + + // Try exact (case-insensitive) match against known source names. + foreach ( $source_names as $name ) { + foreach ( $separators as $sep ) { + $suffix = $sep . $name; + $suffix_len = strlen( $suffix ); + if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) { + return rtrim( substr( $text, 0, -$suffix_len ) ); + } + } + } + + // Fallback 1: strip trailing " - Short Publisher Name" (1-5 words, starts uppercase). + $pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u'; + $candidate = preg_replace( $pattern, '', $text ); + if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) { + return rtrim( $candidate ); + } + + // Fallback 2: strip trailing nbsp-separated publisher names (Google News RSS). + if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) { + if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) { + return rtrim( $m[1] ); + } + } + + return $text; + } + + /** + * Build list of source names to match against for suffix stripping. + * + * @param string $source Article source field. + * @param string $source_url Article source URL. + * @return array + */ + private function collect_source_names( string $source, string $source_url ): array { + $names = array(); + if ( ! empty( $source ) ) { + $names[] = $source; + } + + // Cached distinct source names to avoid DB query per article. + $cached_sources = get_transient( 'peptide_news_source_names' ); + if ( false === $cached_sources ) { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + $cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" ); + if ( ! is_array( $cached_sources ) ) { + $cached_sources = array(); + } + set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS ); + } + $names = array_merge( $names, $cached_sources ); + + // Extract domain-based publisher name from URL. + if ( ! empty( $source_url ) ) { + $host = wp_parse_url( $source_url, PHP_URL_HOST ); + if ( $host ) { + $host = preg_replace( '/^www\./', '', $host ); + $parts = explode( '.', $host ); + if ( count( $parts ) >= 2 ) { + $domain_name = $parts[ count( $parts ) - 2 ]; + $names[] = str_replace( '-', ' ', $domain_name ); + $names[] = $domain_name; + } + } + } + + return $names; + } +} diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 69054b6..476c0a6 100755 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -40,7 +40,9 @@ 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'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm-client.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm-prompt-builder.php'; @@ -53,6 +55,7 @@ class Peptide_News { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-assets.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-menu.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-filter-field-renderers.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-field-renderers.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-settings.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-log-viewer.php'; diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php index 8122ad9..a9817cc 100755 --- a/peptide-news-plugin.php +++ b/peptide-news-plugin.php @@ -3,8 +3,8 @@ * Plugin Name: Peptide News Aggregator * Plugin URI: https://github.com/peptiderepo/peptide-news-plugin * Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting. - * Version: 2.4.0 - * Author: Peptide News Team + * Version: 2.4.1 + * Author: peptiderepo * Author URI: https://github.com/peptiderepo * License: GPL-2.0+ * License URI: http://www.gnu.org/licenses/gpl-2.0.txt