diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php
index 6db4a1c..75d085a 100644
--- a/admin/class-peptide-news-admin.php
+++ b/admin/class-peptide-news-admin.php
@@ -709,6 +709,131 @@ class Peptide_News_Admin {
echo '
' . esc_html__( 'Widget:', 'peptide-news' ) . ' ' .
esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '
';
+ // ── Plugin Log Viewer ──────────────────────────────────────────
+ echo '' . esc_html__( 'Plugin Log', 'peptide-news' ) . '
';
+ echo '' . esc_html__( 'Recent plugin activity — fetches, AI processing, errors, and admin actions.', 'peptide-news' ) . '
';
+
+ // Filters row.
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '
';
+
+ // Log table.
+ echo '';
+ echo '
';
+ echo '';
+ echo '| ' . esc_html__( 'Time', 'peptide-news' ) . ' | ';
+ echo '' . esc_html__( 'Level', 'peptide-news' ) . ' | ';
+ echo '' . esc_html__( 'Context', 'peptide-news' ) . ' | ';
+ echo '' . esc_html__( 'Message', 'peptide-news' ) . ' | ';
+ echo '
';
+ echo '| ' . esc_html__( 'Loading...', 'peptide-news' ) . ' |
';
+ echo '
';
+ echo '
';
+
+ // Pagination.
+ echo '';
+
+ ?>
+
+ ';
}
diff --git a/includes/class-peptide-news-activator.php b/includes/class-peptide-news-activator.php
index 67da210..fb7f024 100644
--- a/includes/class-peptide-news-activator.php
+++ b/includes/class-peptide-news-activator.php
@@ -15,6 +15,11 @@ class Peptide_News_Activator {
self::create_tables();
self::set_default_options();
self::schedule_cron();
+
+ // Create the log table (idempotent via dbDelta).
+ if ( class_exists( 'Peptide_News_Logger' ) ) {
+ Peptide_News_Logger::create_table();
+ }
}
/**
diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php
index 2f33708..a9f03d1 100644
--- a/includes/class-peptide-news-fetcher.php
+++ b/includes/class-peptide-news-fetcher.php
@@ -44,11 +44,13 @@ class Peptide_News_Fetcher {
// Prevent overlapping fetch jobs.
$lock_key = 'peptide_news_fetch_lock';
if ( get_transient( $lock_key ) ) {
- $this->log_error( 'Fetch already in progress. Skipping this cycle.' );
+ Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' );
return;
}
set_transient( $lock_key, true, 300 ); // 5-minute lock.
+ Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' );
+
$articles = array();
// Fetch from RSS feeds.
@@ -89,6 +91,11 @@ class Peptide_News_Fetcher {
'new_stored' => $stored,
) );
+ Peptide_News_Logger::info( sprintf(
+ 'Fetch complete: %d found, %d filtered out, %d new stored.',
+ $pre_filter_count, $filtered_out, $stored
+ ), 'fetch' );
+
// Run AI analysis on newly fetched articles (keywords + summary).
if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) {
$llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) );
diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php
index 80155d9..5aa8804 100644
--- a/includes/class-peptide-news-llm.php
+++ b/includes/class-peptide-news-llm.php
@@ -75,14 +75,15 @@ class Peptide_News_LLM {
if ( ! is_wp_error( $keywords_result ) ) {
$results['keywords'] = self::sanitize_keywords( $keywords_result );
$any_success = true;
+ Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' );
} else {
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message();
$results['errors'][] = $err_msg;
- self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() );
+ Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' );
}
} elseif ( $need_keywords ) {
$results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model;
- self::log_error( 'Invalid keywords model ID: ' . $keywords_model );
+ Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' );
}
// --- Summarization ---
@@ -97,14 +98,15 @@ class Peptide_News_LLM {
// Strip all HTML tags from LLM output to prevent XSS.
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) );
$any_success = true;
+ Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
} else {
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message();
$results['errors'][] = $err_msg;
- self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() );
+ Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' );
}
} elseif ( $need_summary ) {
$results['errors'][] = 'Invalid summary model ID: ' . $summary_model;
- self::log_error( 'Invalid summary model ID: ' . $summary_model );
+ Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' );
}
$results['success'] = $any_success;
@@ -198,6 +200,12 @@ class Peptide_News_LLM {
'errors' => $last_errors,
) );
+ if ( $processed > 0 ) {
+ Peptide_News_Logger::info( sprintf( 'AI batch complete: %d/%d articles processed.', $processed, count( $articles ) ), 'llm' );
+ } elseif ( count( $articles ) > 0 ) {
+ Peptide_News_Logger::warning( sprintf( 'AI batch: 0/%d articles succeeded.', count( $articles ) ), 'llm' );
+ }
+
return $processed;
}
@@ -407,6 +415,8 @@ class Peptide_News_LLM {
wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' );
}
+ Peptide_News_Logger::info( 'Bulk AI summary generation triggered by admin.', 'llm' );
+
// Auto-correct known broken model names to a working free model.
$broken_models = array(
'qwen/qwen3.6-plus:free',
diff --git a/includes/class-peptide-news-logger.php b/includes/class-peptide-news-logger.php
new file mode 100644
index 0000000..83dcd1d
--- /dev/null
+++ b/includes/class-peptide-news-logger.php
@@ -0,0 +1,254 @@
+prefix . 'peptide_news_log';
+ }
+
+ /* ------------------------------------------------------------------
+ * Writing
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Write a log entry.
+ *
+ * @param string $level One of the LEVEL_* constants.
+ * @param string $message Human-readable message.
+ * @param string $context Category tag (fetch, llm, admin, cron, general).
+ */
+ public static function log( $level, $message, $context = 'general' ) {
+ global $wpdb;
+
+ $wpdb->insert(
+ self::table(),
+ array(
+ 'level' => $level,
+ 'context' => $context,
+ 'message' => $message,
+ 'created_at' => current_time( 'mysql' ),
+ ),
+ array( '%s', '%s', '%s', '%s' )
+ );
+
+ // Auto-prune old entries.
+ self::maybe_prune();
+ }
+
+ /** Convenience: info level. */
+ public static function info( $message, $context = 'general' ) {
+ self::log( self::LEVEL_INFO, $message, $context );
+ }
+
+ /** Convenience: warning level. */
+ public static function warning( $message, $context = 'general' ) {
+ self::log( self::LEVEL_WARNING, $message, $context );
+ }
+
+ /** Convenience: error level. */
+ public static function error( $message, $context = 'general' ) {
+ self::log( self::LEVEL_ERROR, $message, $context );
+ }
+
+ /** Convenience: debug level (only writes when WP_DEBUG is on). */
+ public static function debug( $message, $context = 'general' ) {
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ self::log( self::LEVEL_DEBUG, $message, $context );
+ }
+ }
+
+ /* ------------------------------------------------------------------
+ * Reading
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Retrieve log entries.
+ *
+ * @param array $args {
+ * Optional. Query arguments.
+ *
+ * @type string $level Filter by level.
+ * @type string $context Filter by context.
+ * @type int $limit Number of rows.
+ * @type int $offset Pagination offset.
+ * }
+ * @return array { 'rows' => array, 'total' => int }
+ */
+ public static function get_logs( $args = array() ) {
+ global $wpdb;
+
+ $defaults = array(
+ 'level' => '',
+ 'context' => '',
+ 'limit' => 50,
+ 'offset' => 0,
+ );
+ $args = wp_parse_args( $args, $defaults );
+
+ $table = self::table();
+ $where = array( '1=1' );
+ $vals = array();
+
+ if ( ! empty( $args['level'] ) ) {
+ $where[] = 'level = %s';
+ $vals[] = $args['level'];
+ }
+ if ( ! empty( $args['context'] ) ) {
+ $where[] = 'context = %s';
+ $vals[] = $args['context'];
+ }
+
+ $where_sql = implode( ' AND ', $where );
+
+ // Total count.
+ $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
+ $total = empty( $vals )
+ ? (int) $wpdb->get_var( $count_sql )
+ : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) );
+
+ // Rows.
+ $order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
+ $vals[] = absint( $args['limit'] );
+ $vals[] = absint( $args['offset'] );
+ $rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) );
+
+ return array(
+ 'rows' => $rows ? $rows : array(),
+ 'total' => $total,
+ );
+ }
+
+ /* ------------------------------------------------------------------
+ * Maintenance
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Remove rows beyond the MAX_ROWS cap.
+ */
+ private static function maybe_prune() {
+ global $wpdb;
+ $table = self::table();
+
+ // Only prune every ~50 writes (probabilistic).
+ if ( wp_rand( 1, 50 ) !== 1 ) {
+ return;
+ }
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+
+ if ( $count > self::MAX_ROWS ) {
+ $delete_count = $count - self::MAX_ROWS;
+ $wpdb->query( $wpdb->prepare(
+ "DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
+ $delete_count
+ ) );
+ }
+ }
+
+ /**
+ * Clear all log entries.
+ */
+ public static function clear() {
+ global $wpdb;
+ $wpdb->query( "TRUNCATE TABLE " . self::table() );
+ }
+
+ /* ------------------------------------------------------------------
+ * AJAX handlers
+ * ----------------------------------------------------------------*/
+
+ /**
+ * AJAX: fetch paginated log entries.
+ */
+ public static function ajax_get_logs() {
+ check_ajax_referer( 'peptide_news_admin', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'Unauthorized', 403 );
+ }
+
+ $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
+ $level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : '';
+ $context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : '';
+ $per_page = 50;
+
+ $result = self::get_logs( array(
+ 'level' => $level,
+ 'context' => $context,
+ 'limit' => $per_page,
+ 'offset' => ( $page - 1 ) * $per_page,
+ ) );
+
+ wp_send_json_success( array(
+ 'rows' => $result['rows'],
+ 'total' => $result['total'],
+ 'page' => $page,
+ 'total_pages' => ceil( $result['total'] / $per_page ),
+ ) );
+ }
+
+ /**
+ * AJAX: clear all log entries.
+ */
+ public static function ajax_clear_logs() {
+ check_ajax_referer( 'peptide_news_admin', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'Unauthorized', 403 );
+ }
+
+ self::clear();
+ self::info( 'Log cleared by admin.', 'admin' );
+
+ wp_send_json_success( array( 'message' => 'Log cleared.' ) );
+ }
+
+ /* ------------------------------------------------------------------
+ * Schema
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Create the log table (called from activator / upgrade check).
+ */
+ public static function create_table() {
+ global $wpdb;
+
+ $table = self::table();
+ $charset_collate = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE {$table} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ level VARCHAR(10) NOT NULL DEFAULT 'info',
+ context VARCHAR(30) NOT NULL DEFAULT 'general',
+ message TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_level (level),
+ KEY idx_context (context),
+ KEY idx_created (created_at)
+ ) {$charset_collate};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+ }
+}
diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php
index 25a0cc3..669cbb0 100644
--- a/includes/class-peptide-news.php
+++ b/includes/class-peptide-news.php
@@ -39,6 +39,7 @@ class Peptide_News {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.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.php';
+ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
@@ -82,6 +83,8 @@ class Peptide_News {
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
$this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
+ $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' );
+ $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' );
}
/**
diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php
index 7b5aeaf..7598d44 100644
--- a/peptide-news-plugin.php
+++ b/peptide-news-plugin.php
@@ -3,7 +3,7 @@
* 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.0.1
+ * Version: 2.1.0
* Author: Peptide News Team
* Author URI: https://github.com/peptiderepo
* License: GPL-2.0+
@@ -22,7 +22,7 @@ if ( ! defined( 'WPINC' ) ) {
/**
* Current plugin version — follows SemVer.
*/
-define( 'PEPTIDE_NEWS_VERSION', '2.0.1' );
+define( 'PEPTIDE_NEWS_VERSION', '2.1.0' );
define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
@@ -60,6 +60,7 @@ function peptide_news_check_db_upgrade() {
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
+ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
Peptide_News_Activator::activate();
}
}
diff --git a/peptide-news-plugin/admin/class-peptide-news-admin.php b/peptide-news-plugin/admin/class-peptide-news-admin.php
index 6db4a1c..75d085a 100644
--- a/peptide-news-plugin/admin/class-peptide-news-admin.php
+++ b/peptide-news-plugin/admin/class-peptide-news-admin.php
@@ -709,6 +709,131 @@ class Peptide_News_Admin {
echo '' . esc_html__( 'Widget:', 'peptide-news' ) . ' ' .
esc_html__( 'Add "Peptide News" from Appearance > Widgets.', 'peptide-news' ) . '
';
+ // ── Plugin Log Viewer ──────────────────────────────────────────
+ echo '' . esc_html__( 'Plugin Log', 'peptide-news' ) . '
';
+ echo '' . esc_html__( 'Recent plugin activity — fetches, AI processing, errors, and admin actions.', 'peptide-news' ) . '
';
+
+ // Filters row.
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '';
+ echo '
';
+
+ // Log table.
+ echo '';
+ echo '
';
+ echo '';
+ echo '| ' . esc_html__( 'Time', 'peptide-news' ) . ' | ';
+ echo '' . esc_html__( 'Level', 'peptide-news' ) . ' | ';
+ echo '' . esc_html__( 'Context', 'peptide-news' ) . ' | ';
+ echo '' . esc_html__( 'Message', 'peptide-news' ) . ' | ';
+ echo '
';
+ echo '| ' . esc_html__( 'Loading...', 'peptide-news' ) . ' |
';
+ echo '
';
+ echo '
';
+
+ // Pagination.
+ echo '';
+
+ ?>
+
+ ';
}
diff --git a/peptide-news-plugin/includes/class-peptide-news-activator.php b/peptide-news-plugin/includes/class-peptide-news-activator.php
index 5562ba8..fb7f024 100644
--- a/peptide-news-plugin/includes/class-peptide-news-activator.php
+++ b/peptide-news-plugin/includes/class-peptide-news-activator.php
@@ -2,36 +2,146 @@
/**
* Fired during plugin activation.
*
- * Create database tables and dispatch activation hooks.
+ * Creates database tables and schedules the cron event.
*
* @since 1.0.0
*/
-
class Peptide_News_Activator {
+ /**
+ * Create custom tables and schedule cron.
+ */
public static function activate() {
- global $wpdb;
+ self::create_tables();
+ self::set_default_options();
+ self::schedule_cron();
- // Create tables.
- require_once abspath(__FILE__, '../includes/class-peptide-news-activator.php');
+ // Create the log table (idempotent via dbDelta).
+ if ( class_exists( 'Peptide_News_Logger' ) ) {
+ Peptide_News_Logger::create_table();
+ }
+ }
+
+ /**
+ * Create the articles and analytics database tables.
+ */
+ private static function create_tables() {
+ global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
- // Create articles table.
- $wpdb->query( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}peptide_news_articles (
- ID bigint(19) UNSIGNED AUTO_INCREMENT,
- title varchar(255),
- excerpt LONGTEXT,
- content LONGTEXT,
- source varchar(255),
- author varchar(255),
- thumbnail varchar(500),
- source_url text,
- category_id bigint(20),
- published_at datetime,
- fetched_at datetime,
- PRIMARY KEY (ID)
- ){ $upload_file_name}{" WITH PARTITION (TITLE, STTUS;
- * INTEGER ", DROP TIE VABLOCKED IF EXISTS"} WITH (TEMP=da2) ");
+ // Articles table — stores fetched news data for analysis.
+ $articles_table = $wpdb->prefix . 'peptide_news_articles';
+ $sql_articles = "CREATE TABLE {$articles_table} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ source VARCHAR(100) NOT NULL DEFAULT '',
+ source_url VARCHAR(2048) NOT NULL DEFAULT '',
+ title TEXT NOT NULL,
+ excerpt TEXT NOT NULL,
+ content LONGTEXT NOT NULL,
+ author VARCHAR(255) NOT NULL DEFAULT '',
+ thumbnail_url VARCHAR(2048) NOT NULL DEFAULT '',
+ thumbnail_local VARCHAR(512) NOT NULL DEFAULT '',
+ published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ categories VARCHAR(500) NOT NULL DEFAULT '',
+ tags VARCHAR(500) NOT NULL DEFAULT '',
+ language VARCHAR(10) NOT NULL DEFAULT 'en',
+ sentiment_score FLOAT DEFAULT NULL,
+ ai_summary TEXT NOT NULL,
+ hash VARCHAR(64) NOT NULL DEFAULT '',
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (id),
+ UNIQUE KEY hash (hash),
+ KEY idx_published (published_at),
+ KEY idx_source (source),
+ KEY idx_active_published (is_active, published_at)
+ ) {$charset_collate};";
+
+ // Click analytics table — one row per click event.
+ $clicks_table = $wpdb->prefix . 'peptide_news_clicks';
+ $sql_clicks = "CREATE TABLE {$clicks_table} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ article_id BIGINT(20) UNSIGNED NOT NULL,
+ clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ user_ip VARCHAR(45) NOT NULL DEFAULT '',
+ user_agent TEXT NOT NULL,
+ referrer_url VARCHAR(2048) NOT NULL DEFAULT '',
+ page_url VARCHAR(2048) NOT NULL DEFAULT '',
+ session_id VARCHAR(64) NOT NULL DEFAULT '',
+ user_id BIGINT(20) UNSIGNED DEFAULT NULL,
+ country VARCHAR(2) NOT NULL DEFAULT '',
+ device_type VARCHAR(20) NOT NULL DEFAULT '',
+ PRIMARY KEY (id),
+ KEY idx_article (article_id),
+ KEY idx_clicked_at (clicked_at),
+ KEY idx_session (session_id),
+ KEY idx_article_date (article_id, clicked_at)
+ ) {$charset_collate};";
+
+ // Daily aggregates table — for fast trend queries.
+ $daily_table = $wpdb->prefix . 'peptide_news_daily_stats';
+ $sql_daily = "CREATE TABLE {$daily_table} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ article_id BIGINT(20) UNSIGNED NOT NULL,
+ stat_date DATE NOT NULL,
+ click_count INT UNSIGNED NOT NULL DEFAULT 0,
+ unique_visitors INT UNSIGNED NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ UNIQUE KEY idx_article_date (article_id, stat_date),
+ KEY idx_date (stat_date)
+ ) {$charset_collate};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ dbDelta( $sql_articles );
+ dbDelta( $sql_clicks );
+ dbDelta( $sql_daily );
+
+ update_option( 'peptide_news_db_version', PEPTIDE_NEWS_VERSION );
+ }
+
+ /**
+ * Set sensible default option values.
+ */
+ private static function set_default_options() {
+ $defaults = array(
+ 'fetch_interval' => 'twicedaily',
+ 'articles_count' => 10,
+ 'newsapi_key' => '',
+ 'newsapi_enabled' => 0,
+ 'rss_feeds' => implode( "\n", array(
+ 'https://news.google.com/rss/search?q=peptide+research&hl=en-US&gl=US&ceid=US:en',
+ 'https://pubmed.ncbi.nlm.nih.gov/rss/search/1sUlVGjEXm1YN9QiGPblv_-bwB51JCtyLwVX85CVGDKCHFFR9Q/?limit=20&utm_campaign=pubmed-2&fc=20220101000000',
+ ) ),
+ 'rss_enabled' => 1,
+ 'search_keywords' => 'peptide, peptides, peptide therapy, peptide research, BPC-157, Thymosin, GHK-Cu',
+ 'thumbnail_fallback' => PEPTIDE_NEWS_PLUGIN_URL . 'public/images/default-thumb.png',
+ 'article_retention' => 90,
+ 'analytics_retention' => 365,
+ 'anonymize_ip' => 1,
+ 'llm_enabled' => 0,
+ 'openrouter_api_key' => '',
+ 'llm_keywords_model' => 'google/gemini-2.0-flash-001',
+ 'llm_summary_model' => 'google/gemini-2.0-flash-001',
+ 'llm_max_articles' => 10,
+ );
+
+ foreach ( $defaults as $key => $value ) {
+ if ( false === get_option( "peptide_news_{$key}" ) ) {
+ add_option( "peptide_news_{$key}", $value );
+ }
+ }
+ }
+
+ /**
+ * Schedule the cron event based on the configured interval.
+ */
+ private static function schedule_cron() {
+ $interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
+
+ if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) {
+ wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' );
+ }
}
}
diff --git a/peptide-news-plugin/includes/class-peptide-news-fetcher.php b/peptide-news-plugin/includes/class-peptide-news-fetcher.php
index 1bb1d54..a9f03d1 100644
--- a/peptide-news-plugin/includes/class-peptide-news-fetcher.php
+++ b/peptide-news-plugin/includes/class-peptide-news-fetcher.php
@@ -1,15 +1,544 @@
900,
+ 'display' => __( 'Every 15 Minutes', 'peptide-news' ),
+ );
+ $schedules['every_thirty_minutes'] = array(
+ 'interval' => 1800,
+ 'display' => __( 'Every 30 Minutes', 'peptide-news' ),
+ );
+ $schedules['every_four_hours'] = array(
+ 'interval' => 14400,
+ 'display' => __( 'Every 4 Hours', 'peptide-news' ),
+ );
+ $schedules['every_six_hours'] = array(
+ 'interval' => 21600,
+ 'display' => __( 'Every 6 Hours', 'peptide-news' ),
+ );
+ return $schedules;
+ }
+
+ /**
+ * Master fetch method — called by WP-Cron.
+ * Uses a transient lock to prevent concurrent executions.
+ */
+ public function fetch_all_sources() {
+ // Prevent overlapping fetch jobs.
+ $lock_key = 'peptide_news_fetch_lock';
+ if ( get_transient( $lock_key ) ) {
+ Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' );
+ return;
+ }
+ set_transient( $lock_key, true, 300 ); // 5-minute lock.
+
+ Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' );
+
+ $articles = array();
+
+ // Fetch from RSS feeds.
+ if ( get_option( 'peptide_news_rss_enabled', 1 ) ) {
+ $rss_articles = $this->fetch_rss_feeds();
+ $articles = array_merge( $articles, $rss_articles );
+ }
+
+ // Fetch from NewsAPI.
+ if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) {
+ $api_key = get_option( 'peptide_news_newsapi_key', '' );
+ if ( ! empty( $api_key ) ) {
+ $newsapi_articles = $this->fetch_newsapi( $api_key );
+ $articles = array_merge( $articles, $newsapi_articles );
+ }
+ }
+
+ // Filter out ads, press releases, and promotional content.
+ $pre_filter_count = count( $articles );
+ if ( class_exists( 'Peptide_News_Content_Filter' ) && Peptide_News_Content_Filter::is_enabled() ) {
+ $articles = Peptide_News_Content_Filter::filter_articles( $articles );
+ }
+ $filtered_out = $pre_filter_count - count( $articles );
+
+ // Store articles (deduplication handled by hash).
+ $stored = 0;
+ foreach ( $articles as $article ) {
+ if ( $this->store_article( $article ) ) {
+ $stored++;
+ }
+ }
+
+ // Log the fetch result.
+ update_option( 'peptide_news_last_fetch', array(
+ 'time' => current_time( 'mysql' ),
+ 'found' => $pre_filter_count,
+ 'filtered_out' => $filtered_out,
+ 'new_stored' => $stored,
+ ) );
+
+ Peptide_News_Logger::info( sprintf(
+ 'Fetch complete: %d found, %d filtered out, %d new stored.',
+ $pre_filter_count, $filtered_out, $stored
+ ), 'fetch' );
+
+ // Run AI analysis on newly fetched articles (keywords + summary).
+ if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) {
+ $llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) );
+ $llm_processed = Peptide_News_LLM::process_unanalyzed( $llm_batch_size );
+
+ // Append LLM stats to the fetch log.
+ $fetch_log = get_option( 'peptide_news_last_fetch' );
+ if ( is_array( $fetch_log ) ) {
+ $fetch_log['ai_processed'] = $llm_processed;
+ update_option( 'peptide_news_last_fetch', $fetch_log );
+ }
+ }
+
+ // Prune old articles beyond retention period.
+ $this->prune_old_articles();
+
+ // Release the fetch lock.
+ delete_transient( $lock_key );
+ }
+
+ /**
+ * Fetch articles from configured RSS feeds.
+ *
+ * @return array
+ */
+ private function fetch_rss_feeds() {
+ $feeds_raw = get_option( 'peptide_news_rss_feeds', '' );
+ $feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) );
+ $articles = array();
+
+ if ( ! function_exists( 'fetch_feed' ) ) {
+ require_once ABSPATH . WPINC . '/feed.php';
+ }
+
+ foreach ( $feeds as $feed_url ) {
+ $feed = fetch_feed( esc_url_raw( $feed_url ) );
+
+ if ( is_wp_error( $feed ) ) {
+ $this->log_error( 'RSS fetch failed for ' . $feed_url . ': ' . $feed->get_error_message() );
+ continue;
+ }
+
+ $max_items = $feed->get_item_quantity( 50 );
+ $items = $feed->get_items( 0, $max_items );
+
+ foreach ( $items as $item ) {
+ $pub_date = $item->get_date( 'Y-m-d H:i:s' );
+ if ( empty( $pub_date ) ) {
+ $pub_date = current_time( 'mysql' );
+ }
+
+ $article_url = esc_url_raw( $item->get_permalink() );
+ $source_name = $this->resolve_article_source( $item, $feed_url, $article_url );
+
+ $articles[] = array(
+ 'source' => $source_name,
+ 'source_url' => $article_url,
+ 'title' => sanitize_text_field( $item->get_title() ),
+ 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ),
+ 'content' => wp_kses_post( $item->get_content() ),
+ 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ),
+ 'thumbnail_url' => '',
+ 'published_at' => $pub_date,
+ 'categories' => $this->extract_categories( $item ),
+ 'tags' => '',
+ 'language' => 'en',
+ );
+ }
+ }
+
+ return $articles;
+ }
+
+ /**
+ * Fetch articles from NewsAPI.org.
+ *
+ * @param string $api_key
+ * @return array
+ */
+ private function fetch_newsapi( $api_key ) {
+ $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' );
+ $articles = array();
+
+ $url = add_query_arg( array(
+ 'q' => urlencode( $keywords ),
+ 'language' => 'en',
+ 'sortBy' => 'publishedAt',
+ 'pageSize' => 50,
+ 'apiKey' => $api_key,
+ ), 'https://newsapi.org/v2/everything' );
+
+ $response = wp_remote_get( $url, array(
+ 'timeout' => 30,
+ 'headers' => array( 'User-Agent' => 'PeptideNewsWP/' . PEPTIDE_NEWS_VERSION ),
+ ) );
+
+ if ( is_wp_error( $response ) ) {
+ $this->log_error( 'NewsAPI fetch failed: ' . $response->get_error_message() );
+ return $articles;
+ }
+
+ $body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+ if ( empty( $body['articles'] ) || 'ok' !== ( $body['status'] ?? '' ) ) {
+ $this->log_error( 'NewsAPI returned no articles or error status.' );
+ return $articles;
+ }
+
+ foreach ( $body['articles'] as $item ) {
+ $pub_date = isset( $item['publishedAt'] )
+ ? gmdate( 'Y-m-d H:i:s', strtotime( $item['publishedAt'] ) )
+ : current_time( 'mysql' );
+
+ $articles[] = array(
+ 'source' => sanitize_text_field( $item['source']['name'] ?? 'NewsAPI' ),
+ 'source_url' => esc_url_raw( $item['url'] ?? '' ),
+ 'title' => sanitize_text_field( $item['title'] ?? '' ),
+ 'excerpt' => wp_trim_words( sanitize_text_field( $item['description'] ?? '' ), 40 ),
+ 'content' => wp_kses_post( $item['content'] ?? '' ),
+ 'author' => sanitize_text_field( $item['author'] ?? '' ),
+ 'thumbnail_url' => '',
+ 'published_at' => $pub_date,
+ 'categories' => '',
+ 'tags' => '',
+ 'language' => 'en',
+ );
+ }
+
+ return $articles;
+ }
+
+ /**
+ * Store an article in the database. Deduplication via SHA-256 hash of URL.
+ *
+ * @param array $article
+ * @return bool True if new article was inserted.
+ */
+ private function store_article( $article ) {
global $wpdb;
- // Fetch from NewsAP
\ No newline at end of file
+ $table = $wpdb->prefix . 'peptide_news_articles';
+ $hash = hash( 'sha256', $article['source_url'] );
+
+ // Check for duplicate.
+ $exists = $wpdb->get_var( $wpdb->prepare(
+ "SELECT id FROM {$table} WHERE hash = %s",
+ $hash
+ ) );
+
+ if ( $exists ) {
+ return false;
+ }
+
+ $result = $wpdb->insert(
+ $table,
+ array(
+ 'source' => $article['source'],
+ 'source_url' => $article['source_url'],
+ 'title' => $article['title'],
+ 'excerpt' => $article['excerpt'],
+ 'content' => $article['content'],
+ 'author' => $article['author'],
+ 'thumbnail_url' => $article['thumbnail_url'],
+ 'published_at' => $article['published_at'],
+ 'fetched_at' => current_time( 'mysql' ),
+ 'categories' => $article['categories'],
+ 'tags' => $article['tags'],
+ 'language' => $article['language'],
+ 'hash' => $hash,
+ 'is_active' => 1,
+ ),
+ array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' )
+ );
+
+ return false !== $result;
+ }
+
+ /**
+ * Remove articles older than the retention period.
+ */
+ private function prune_old_articles() {
+ global $wpdb;
+
+ $retention_days = (int) get_option( 'peptide_news_article_retention', 90 );
+ $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) );
+
+ $table = $wpdb->prefix . 'peptide_news_articles';
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET is_active = 0 WHERE published_at < %s",
+ $cutoff
+ ) );
+ }
+
+ /**
+ * Clear all article transient caches.
+ */
+ private function clear_article_cache() {
+ global $wpdb;
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options}
+ WHERE option_name LIKE %s
+ OR option_name LIKE %s",
+ $wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%',
+ $wpdb->esc_like( '_transient_timeout_peptide_news_articles_' ) . '%'
+ )
+ );
+ }
+
+ /**
+ * Extract categories from a SimplePie item.
+ *
+ * @param SimplePie_Item $item
+ * @return string Comma-separated categories.
+ */
+ private function extract_categories( $item ) {
+ $categories = $item->get_categories();
+ if ( empty( $categories ) ) {
+ return '';
+ }
+
+ $names = array();
+ foreach ( $categories as $cat ) {
+ $names[] = sanitize_text_field( $cat->get_label() );
+ }
+
+ return implode( ', ', array_filter( $names ) );
+ }
+
+ /**
+ * Resolve the real source name for an RSS feed item.
+ *
+ * Google News RSS wraps articles from other publishers behind
+ * news.google.com URLs, so the feed-level domain is useless.
+ * This method tries several strategies in priority order:
+ *
+ * 1. SimplePie element (Google News RSS includes this).
+ * 2. Title suffix — Google News formats titles as "Headline - Source".
+ * 3. Resolve the redirect URL to get the actual publisher domain.
+ * 4. Fall back to the article permalink domain.
+ * 5. Last resort: feed URL domain.
+ *
+ * @param SimplePie_Item $item The RSS item.
+ * @param string $feed_url The feed URL.
+ * @param string $article_url The article permalink.
+ * @return string Source name or domain.
+ */
+ private function resolve_article_source( $item, $feed_url, $article_url ) {
+ $feed_domain = $this->extract_domain( $feed_url );
+ $is_aggregator = in_array(
+ $feed_domain,
+ array( 'news.google.com', 'news.yahoo.com', 'msn.com', 'feedly.com' ),
+ true
+ );
+
+ // Strategy 1: SimplePie element.
+ $source_obj = $item->get_source();
+ if ( $source_obj ) {
+ $source_title = $source_obj->get_title();
+ if ( ! empty( $source_title ) ) {
+ return sanitize_text_field( $source_title );
+ }
+ $source_link = $source_obj->get_link();
+ if ( ! empty( $source_link ) ) {
+ $domain = $this->extract_domain( $source_link );
+ if ( 'unknown' !== $domain ) {
+ return $domain;
+ }
+ }
+ }
+
+ // Strategy 2: Parse "Headline - Source" from title (common in aggregator feeds).
+ if ( $is_aggregator ) {
+ $title = $item->get_title();
+ if ( ! empty( $title ) && preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $title, $matches ) ) {
+ $candidate = trim( $matches[1] );
+ if ( mb_strlen( $candidate ) <= 60 && mb_strlen( $candidate ) >= 2 ) {
+ return sanitize_text_field( $candidate );
+ }
+ }
+ }
+
+ // Strategy 3: Resolve Google News redirect URL to actual destination.
+ if ( $is_aggregator ) {
+ $resolved = $this->resolve_redirect_url( $article_url );
+ if ( $resolved && $resolved !== $article_url ) {
+ $domain = $this->extract_domain( $resolved );
+ if ( 'unknown' !== $domain && $domain !== $feed_domain ) {
+ return $domain;
+ }
+ }
+ }
+
+ // Strategy 4: Use the article permalink domain.
+ $article_domain = $this->extract_domain( $article_url );
+ if ( 'unknown' !== $article_domain && $article_domain !== $feed_domain ) {
+ return $article_domain;
+ }
+
+ // Strategy 5: Fall back to the feed domain.
+ return $feed_domain;
+ }
+
+ /**
+ * Resolve a redirect URL to its final destination without downloading the body.
+ *
+ * @param string $url The URL to resolve.
+ * @return string|false The final URL or false on failure.
+ */
+ private function resolve_redirect_url( $url ) {
+ if ( empty( $url ) ) {
+ return false;
+ }
+
+ $response = wp_remote_head( $url, array(
+ 'timeout' => 8,
+ 'redirection' => 5,
+ 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)',
+ ) );
+
+ if ( is_wp_error( $response ) ) {
+ return false;
+ }
+
+ $final_url = wp_remote_retrieve_header( $response, 'location' );
+
+ if ( empty( $final_url ) && isset( $response['http_response'] ) ) {
+ $http_response = $response['http_response'];
+ if ( method_exists( $http_response, 'get_response_object' ) ) {
+ $raw = $http_response->get_response_object();
+ if ( isset( $raw->url ) ) {
+ return $raw->url;
+ }
+ }
+ }
+
+ return ! empty( $final_url ) ? $final_url : false;
+ }
+
+ /**
+ * Backfill source names for existing articles that show an aggregator domain.
+ *
+ * Parses the " - Source" suffix from stored titles to update the source column.
+ *
+ * @return int Number of articles updated.
+ */
+ public function backfill_article_sources() {
+ global $wpdb;
+
+ $table = $wpdb->prefix . 'peptide_news_articles';
+ $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' );
+ $placeholders = implode( ', ', array_fill( 0, count( $aggregators ), '%s' ) );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $articles = $wpdb->get_results( $wpdb->prepare(
+ "SELECT id, title, source, source_url
+ FROM {$table}
+ WHERE source IN ({$placeholders})
+ AND is_active = 1",
+ ...$aggregators
+ ) );
+
+ if ( empty( $articles ) ) {
+ return 0;
+ }
+
+ $updated = 0;
+
+ foreach ( $articles as $article ) {
+ $new_source = '';
+
+ // Try parsing "Headline - Source" from title.
+ if ( preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $article->title, $matches ) ) {
+ $candidate = trim( $matches[1] );
+ if ( mb_strlen( $candidate ) <= 60 && mb_strlen( $candidate ) >= 2 ) {
+ $new_source = sanitize_text_field( $candidate );
+ }
+ }
+
+ // Fall back to resolving the redirect URL.
+ if ( empty( $new_source ) ) {
+ $resolved = $this->resolve_redirect_url( $article->source_url );
+ if ( $resolved && $resolved !== $article->source_url ) {
+ $domain = $this->extract_domain( $resolved );
+ if ( 'unknown' !== $domain && ! in_array( $domain, $aggregators, true ) ) {
+ $new_source = $domain;
+ }
+ }
+ usleep( 300000 ); // 0.3s throttle for HTTP requests.
+ }
+
+ if ( ! empty( $new_source ) && $new_source !== $article->source ) {
+ $wpdb->update(
+ $table,
+ array( 'source' => $new_source ),
+ array( 'id' => $article->id ),
+ array( '%s' ),
+ array( '%d' )
+ );
+ $updated++;
+ }
+ }
+
+ if ( $updated > 0 ) {
+ $this->clear_article_cache();
+ }
+
+ return $updated;
+ }
+
+ /**
+ * AJAX handler for the backfill action.
+ */
+ public function ajax_backfill_sources() {
+ check_ajax_referer( 'peptide_news_admin', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'Unauthorized', 403 );
+ }
+
+ $updated = $this->backfill_article_sources();
+ wp_send_json_success( array( 'updated' => $updated ) );
+ }
+
+ /**
+ * Extract domain from a URL for source identification.
+ *
+ * @param string $url
+ * @return string
+ */
+ private function extract_domain( $url ) {
+ $host = wp_parse_url( $url, PHP_URL_HOST );
+ return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown';
+ }
+
+ /**
+ * Log an error to the WordPress debug log.
+ *
+ * @param string $message
+ */
+ private function log_error( $message ) {
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ error_log( '[Peptide News] ' . $message );
+ }
+ }
+}
diff --git a/peptide-news-plugin/includes/class-peptide-news-llm.php b/peptide-news-plugin/includes/class-peptide-news-llm.php
index 80155d9..5aa8804 100644
--- a/peptide-news-plugin/includes/class-peptide-news-llm.php
+++ b/peptide-news-plugin/includes/class-peptide-news-llm.php
@@ -75,14 +75,15 @@ class Peptide_News_LLM {
if ( ! is_wp_error( $keywords_result ) ) {
$results['keywords'] = self::sanitize_keywords( $keywords_result );
$any_success = true;
+ Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' );
} else {
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message();
$results['errors'][] = $err_msg;
- self::log_error( 'Keyword extraction failed for article ' . $article->id . ': ' . $keywords_result->get_error_message() );
+ Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' );
}
} elseif ( $need_keywords ) {
$results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model;
- self::log_error( 'Invalid keywords model ID: ' . $keywords_model );
+ Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' );
}
// --- Summarization ---
@@ -97,14 +98,15 @@ class Peptide_News_LLM {
// Strip all HTML tags from LLM output to prevent XSS.
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) );
$any_success = true;
+ Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
} else {
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message();
$results['errors'][] = $err_msg;
- self::log_error( 'Summarization failed for article ' . $article->id . ': ' . $summary_result->get_error_message() );
+ Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' );
}
} elseif ( $need_summary ) {
$results['errors'][] = 'Invalid summary model ID: ' . $summary_model;
- self::log_error( 'Invalid summary model ID: ' . $summary_model );
+ Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' );
}
$results['success'] = $any_success;
@@ -198,6 +200,12 @@ class Peptide_News_LLM {
'errors' => $last_errors,
) );
+ if ( $processed > 0 ) {
+ Peptide_News_Logger::info( sprintf( 'AI batch complete: %d/%d articles processed.', $processed, count( $articles ) ), 'llm' );
+ } elseif ( count( $articles ) > 0 ) {
+ Peptide_News_Logger::warning( sprintf( 'AI batch: 0/%d articles succeeded.', count( $articles ) ), 'llm' );
+ }
+
return $processed;
}
@@ -407,6 +415,8 @@ class Peptide_News_LLM {
wp_send_json_error( 'AI Analysis is not enabled. Please enable it and set an OpenRouter API key in Peptide News → Settings.' );
}
+ Peptide_News_Logger::info( 'Bulk AI summary generation triggered by admin.', 'llm' );
+
// Auto-correct known broken model names to a working free model.
$broken_models = array(
'qwen/qwen3.6-plus:free',
diff --git a/peptide-news-plugin/includes/class-peptide-news-logger.php b/peptide-news-plugin/includes/class-peptide-news-logger.php
new file mode 100644
index 0000000..83dcd1d
--- /dev/null
+++ b/peptide-news-plugin/includes/class-peptide-news-logger.php
@@ -0,0 +1,254 @@
+prefix . 'peptide_news_log';
+ }
+
+ /* ------------------------------------------------------------------
+ * Writing
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Write a log entry.
+ *
+ * @param string $level One of the LEVEL_* constants.
+ * @param string $message Human-readable message.
+ * @param string $context Category tag (fetch, llm, admin, cron, general).
+ */
+ public static function log( $level, $message, $context = 'general' ) {
+ global $wpdb;
+
+ $wpdb->insert(
+ self::table(),
+ array(
+ 'level' => $level,
+ 'context' => $context,
+ 'message' => $message,
+ 'created_at' => current_time( 'mysql' ),
+ ),
+ array( '%s', '%s', '%s', '%s' )
+ );
+
+ // Auto-prune old entries.
+ self::maybe_prune();
+ }
+
+ /** Convenience: info level. */
+ public static function info( $message, $context = 'general' ) {
+ self::log( self::LEVEL_INFO, $message, $context );
+ }
+
+ /** Convenience: warning level. */
+ public static function warning( $message, $context = 'general' ) {
+ self::log( self::LEVEL_WARNING, $message, $context );
+ }
+
+ /** Convenience: error level. */
+ public static function error( $message, $context = 'general' ) {
+ self::log( self::LEVEL_ERROR, $message, $context );
+ }
+
+ /** Convenience: debug level (only writes when WP_DEBUG is on). */
+ public static function debug( $message, $context = 'general' ) {
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ self::log( self::LEVEL_DEBUG, $message, $context );
+ }
+ }
+
+ /* ------------------------------------------------------------------
+ * Reading
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Retrieve log entries.
+ *
+ * @param array $args {
+ * Optional. Query arguments.
+ *
+ * @type string $level Filter by level.
+ * @type string $context Filter by context.
+ * @type int $limit Number of rows.
+ * @type int $offset Pagination offset.
+ * }
+ * @return array { 'rows' => array, 'total' => int }
+ */
+ public static function get_logs( $args = array() ) {
+ global $wpdb;
+
+ $defaults = array(
+ 'level' => '',
+ 'context' => '',
+ 'limit' => 50,
+ 'offset' => 0,
+ );
+ $args = wp_parse_args( $args, $defaults );
+
+ $table = self::table();
+ $where = array( '1=1' );
+ $vals = array();
+
+ if ( ! empty( $args['level'] ) ) {
+ $where[] = 'level = %s';
+ $vals[] = $args['level'];
+ }
+ if ( ! empty( $args['context'] ) ) {
+ $where[] = 'context = %s';
+ $vals[] = $args['context'];
+ }
+
+ $where_sql = implode( ' AND ', $where );
+
+ // Total count.
+ $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
+ $total = empty( $vals )
+ ? (int) $wpdb->get_var( $count_sql )
+ : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) );
+
+ // Rows.
+ $order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
+ $vals[] = absint( $args['limit'] );
+ $vals[] = absint( $args['offset'] );
+ $rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) );
+
+ return array(
+ 'rows' => $rows ? $rows : array(),
+ 'total' => $total,
+ );
+ }
+
+ /* ------------------------------------------------------------------
+ * Maintenance
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Remove rows beyond the MAX_ROWS cap.
+ */
+ private static function maybe_prune() {
+ global $wpdb;
+ $table = self::table();
+
+ // Only prune every ~50 writes (probabilistic).
+ if ( wp_rand( 1, 50 ) !== 1 ) {
+ return;
+ }
+
+ $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
+
+ if ( $count > self::MAX_ROWS ) {
+ $delete_count = $count - self::MAX_ROWS;
+ $wpdb->query( $wpdb->prepare(
+ "DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
+ $delete_count
+ ) );
+ }
+ }
+
+ /**
+ * Clear all log entries.
+ */
+ public static function clear() {
+ global $wpdb;
+ $wpdb->query( "TRUNCATE TABLE " . self::table() );
+ }
+
+ /* ------------------------------------------------------------------
+ * AJAX handlers
+ * ----------------------------------------------------------------*/
+
+ /**
+ * AJAX: fetch paginated log entries.
+ */
+ public static function ajax_get_logs() {
+ check_ajax_referer( 'peptide_news_admin', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'Unauthorized', 403 );
+ }
+
+ $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
+ $level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : '';
+ $context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : '';
+ $per_page = 50;
+
+ $result = self::get_logs( array(
+ 'level' => $level,
+ 'context' => $context,
+ 'limit' => $per_page,
+ 'offset' => ( $page - 1 ) * $per_page,
+ ) );
+
+ wp_send_json_success( array(
+ 'rows' => $result['rows'],
+ 'total' => $result['total'],
+ 'page' => $page,
+ 'total_pages' => ceil( $result['total'] / $per_page ),
+ ) );
+ }
+
+ /**
+ * AJAX: clear all log entries.
+ */
+ public static function ajax_clear_logs() {
+ check_ajax_referer( 'peptide_news_admin', 'nonce' );
+
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_send_json_error( 'Unauthorized', 403 );
+ }
+
+ self::clear();
+ self::info( 'Log cleared by admin.', 'admin' );
+
+ wp_send_json_success( array( 'message' => 'Log cleared.' ) );
+ }
+
+ /* ------------------------------------------------------------------
+ * Schema
+ * ----------------------------------------------------------------*/
+
+ /**
+ * Create the log table (called from activator / upgrade check).
+ */
+ public static function create_table() {
+ global $wpdb;
+
+ $table = self::table();
+ $charset_collate = $wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE {$table} (
+ id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
+ level VARCHAR(10) NOT NULL DEFAULT 'info',
+ context VARCHAR(30) NOT NULL DEFAULT 'general',
+ message TEXT NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_level (level),
+ KEY idx_context (context),
+ KEY idx_created (created_at)
+ ) {$charset_collate};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta( $sql );
+ }
+}
diff --git a/peptide-news-plugin/includes/class-peptide-news.php b/peptide-news-plugin/includes/class-peptide-news.php
index 25a0cc3..669cbb0 100644
--- a/peptide-news-plugin/includes/class-peptide-news.php
+++ b/peptide-news-plugin/includes/class-peptide-news.php
@@ -39,6 +39,7 @@ class Peptide_News {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.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.php';
+ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
@@ -82,6 +83,8 @@ class Peptide_News {
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
$this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
+ $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' );
+ $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' );
}
/**
diff --git a/peptide-news-plugin/peptide-news-plugin.php b/peptide-news-plugin/peptide-news-plugin.php
index 7b5aeaf..7598d44 100644
--- a/peptide-news-plugin/peptide-news-plugin.php
+++ b/peptide-news-plugin/peptide-news-plugin.php
@@ -3,7 +3,7 @@
* 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.0.1
+ * Version: 2.1.0
* Author: Peptide News Team
* Author URI: https://github.com/peptiderepo
* License: GPL-2.0+
@@ -22,7 +22,7 @@ if ( ! defined( 'WPINC' ) ) {
/**
* Current plugin version — follows SemVer.
*/
-define( 'PEPTIDE_NEWS_VERSION', '2.0.1' );
+define( 'PEPTIDE_NEWS_VERSION', '2.1.0' );
define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
@@ -60,6 +60,7 @@ function peptide_news_check_db_upgrade() {
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
+ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
Peptide_News_Activator::activate();
}
}