From 5d4e73b947efe503ab3290f5caa5ecf0508f6ac6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 12 Apr 2026 10:29:18 +0000 Subject: [PATCH] Deploy 6796121 from main --- admin/class-peptide-news-admin.php | 1878 +++++++++-------- admin/partials/dashboard.php | 222 +- includes/class-peptide-news-activator.php | 314 +-- includes/class-peptide-news-analytics.php | 596 +++--- .../class-peptide-news-content-filter.php | 4 +- includes/class-peptide-news-deactivator.php | 6 +- includes/class-peptide-news-fetcher.php | 1188 +++++------ includes/class-peptide-news-llm.php | 1142 +++++----- includes/class-peptide-news-loader.php | 194 +- includes/class-peptide-news-logger.php | 404 ++-- includes/class-peptide-news-rest-api.php | 734 +++---- includes/class-peptide-news.php | 178 +- peptide-news-plugin.php | 26 +- public/class-peptide-news-public.php | 368 ++-- 14 files changed, 3633 insertions(+), 3621 deletions(-) diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php index 486a749..eb7465b 100644 --- a/admin/class-peptide-news-admin.php +++ b/admin/class-peptide-news-admin.php @@ -8,937 +8,949 @@ */ class Peptide_News_Admin { - /** @var string */ - private $plugin_name; - - /** @var string */ - private $version; - - public function __construct( $plugin_name, $version ) { - $this->plugin_name = $plugin_name; - $this->version = $version; - } - - /** - * Enqueue admin CSS. - */ - public function enqueue_styles( $hook ) { - if ( strpos( $hook, 'peptide-news' ) === false ) { - return; - } - wp_enqueue_style( - $this->plugin_name . '-admin', - PEPTIDE_NEWS_PLUGIN_URL . 'admin/css/admin-style.css', - array(), - $this->version, - 'all' - ); - } - - /** - * Enqueue admin JS. - */ - public function enqueue_scripts( $hook ) { - if ( strpos( $hook, 'peptide-news' ) === false ) { - return; - } - - // Chart.js for analytics dashboard. - wp_enqueue_script( - 'chartjs', - plugins_url( 'vendor/chartjs/chart.umd.min.js', __FILE__ ), - array(), - '4.4.0', - true - ); - - wp_enqueue_script( - $this->plugin_name . '-admin', - PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js', - array( 'jquery', 'chartjs' ), - $this->version, - true - ); - - wp_localize_script( $this->plugin_name . '-admin', 'peptideNewsAdmin', array( - 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'rest_url' => rest_url( 'peptide-news/v1/' ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'admin_nonce' => wp_create_nonce( 'peptide_news_admin' ), - ) ); - } - - /** - * Add admin menu pages. - */ - public function add_admin_menu() { - // Main menu — Analytics Dashboard. - add_menu_page( - __( 'Peptide News', 'peptide-news' ), - __( 'Peptide News', 'peptide-news' ), - 'manage_options', - 'peptide-news-dashboard', - array( $this, 'render_dashboard_page' ), - 'dashicons-rss', - 30 - ); - - // Sub-menu — Settings. - add_submenu_page( - 'peptide-news-dashboard', - __( 'Settings', 'peptide-news' ), - __( 'Settings', 'peptide-news' ), - 'manage_options', - 'peptide-news-settings', - array( $this, 'render_settings_page' ) - ); - - // Sub-menu — Articles. - add_submenu_page( - 'peptide-news-dashboard', - __( 'Articles', 'peptide-news' ), - __( 'Articles', 'peptide-news' ), - 'manage_options', - 'peptide-news-articles', - array( $this, 'render_articles_page' ) - ); - } - - /** - * Register all settings. - */ - public function register_settings() { - - // --- General section --- - add_settings_section( - 'peptide_news_general', - __( 'General Settings', 'peptide-news' ), - function () { - echo '

' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '

'; - }, - 'peptide-news-settings' - ); - - $this->add_setting( 'fetch_interval', __( 'Fetch Interval', 'peptide-news' ), 'render_fetch_interval_field', 'peptide_news_general' ); - $this->add_setting( 'articles_count', __( 'Articles to Display', 'peptide-news' ), 'render_articles_count_field', 'peptide_news_general' ); - $this->add_setting( 'search_keywords', __( 'Search Keywords', 'peptide-news' ), 'render_search_keywords_field', 'peptide_news_general' ); - - // --- RSS section --- - add_settings_section( - 'peptide_news_rss', - __( 'RSS Feed Sources', 'peptide-news' ), - function () { - echo '

' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '

'; - }, - 'peptide-news-settings' - ); - - $this->add_setting( 'rss_enabled', __( 'Enable RSS Feeds', 'peptide-news' ), 'render_rss_enabled_field', 'peptide_news_rss' ); - $this->add_setting( 'rss_feeds', __( 'RSS Feed URLs', 'peptide-news' ), 'render_rss_feeds_field', 'peptide_news_rss' ); - - // --- NewsAPI section --- - add_settings_section( - 'peptide_news_newsapi', - __( 'NewsAPI.org', 'peptide-news' ), - function () { - echo '

' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '

'; - }, - 'peptide-news-settings' - ); - - $this->add_setting( 'newsapi_enabled', __( 'Enable NewsAPI', 'peptide-news' ), 'render_newsapi_enabled_field', 'peptide_news_newsapi' ); - $this->add_setting( 'newsapi_key', __( 'API Key', 'peptide-news' ), 'render_newsapi_key_field', 'peptide_news_newsapi' ); - - // --- Analytics section --- - add_settings_section( - 'peptide_news_analytics_section', - __( 'Analytics', 'peptide-news' ), - function () { - echo '

' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '

'; - }, - 'peptide-news-settings' - ); - - $this->add_setting( 'article_retention', __( 'Article Retention (days)', 'peptide-news' ), 'render_article_retention_field', 'peptide_news_analytics_section' ); - $this->add_setting( 'analytics_retention', __( 'Analytics Data Retention (days)', 'peptide-news' ), 'render_retention_field', 'peptide_news_analytics_section' ); - $this->add_setting( 'anonymize_ip', __( 'Anonymize IPs', 'peptide-news' ), 'render_anonymize_ip_field', 'peptide_news_analytics_section' ); - - // --- Content Filter section --- - add_settings_section( - 'peptide_news_filter_section', - __( 'Content Filter', 'peptide-news' ), - function () { - echo '

' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '

'; - $last_run = get_option( 'peptide_news_filter_last_run' ); - if ( $last_run && is_array( $last_run ) ) { - printf( - '

%s: %s | %s: %d | %s: %d | %s: %d | %s: %d

', - esc_html__( 'Last filter run', 'peptide-news' ), - esc_html( $last_run['time'] ), - esc_html__( 'Evaluated', 'peptide-news' ), - absint( $last_run['total'] ), - esc_html__( 'Removed', 'peptide-news' ), - absint( $last_run['removed'] ), - esc_html__( 'LLM checked', 'peptide-news' ), - absint( $last_run['llm_checked'] ), - esc_html__( 'Passed', 'peptide-news' ), - absint( $last_run['passed'] ) - ); - } - }, - 'peptide-news-settings' - ); - - $this->add_setting( 'filter_enabled', __( 'Enable Content Filter', 'peptide-news' ), 'render_filter_enabled_field', 'peptide_news_filter_section' ); - $this->add_setting( 'filter_sensitivity', __( 'Filter Sensitivity', 'peptide-news' ), 'render_filter_sensitivity_field', 'peptide_news_filter_section' ); - $this->add_setting( 'filter_llm_enabled', __( 'LLM Classification', 'peptide-news' ), 'render_filter_llm_enabled_field', 'peptide_news_filter_section' ); - $this->add_setting( 'filter_llm_model', __( 'Filter LLM Model', 'peptide-news' ), 'render_filter_llm_model_field', 'peptide_news_filter_section' ); - $this->add_setting( 'filter_title_keywords', __( 'Title Keywords', 'peptide-news' ), 'render_filter_title_keywords_field', 'peptide_news_filter_section' ); - $this->add_setting( 'filter_body_keywords', __( 'Body Keywords', 'peptide-news' ), 'render_filter_body_keywords_field', 'peptide_news_filter_section' ); - $this->add_setting( 'filter_blocked_domains', __( 'Blocked Domains', 'peptide-news' ), 'render_filter_blocked_domains_field', 'peptide_news_filter_section' ); - - // --- AI / LLM section --- - add_settings_section( - 'peptide_news_llm_section', - __( 'AI Analysis (OpenRouter)', 'peptide-news' ), - function () { - echo '

' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '

'; - }, - 'peptide-news-settings' - ); - - $this->add_setting( 'llm_enabled', __( 'Enable AI Analysis', 'peptide-news' ), 'render_llm_enabled_field', 'peptide_news_llm_section' ); - $this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' ); - $this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' ); - $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); - - $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); - } - - /** - * Helper: register a single setting with a field callback. - */ - private function add_setting( $key, $label, $callback, $section ) { - $option_name = "peptide_news_{$key}"; - - $sanitize_cb = $this->get_sanitize_callback( $key ); - - register_setting( 'peptide_news_settings_group', $option_name, array( - 'sanitize_callback' => $sanitize_cb, - ) ); - - add_settings_field( - $option_name, - $label, - array( $this, $callback ), - 'peptide-news-settings', - $section - ); - } - - /** - * Return the appropriate sanitize callback for a given setting key. - * - * @param string $key Setting key (without prefix). - * @return callable - */ - private function get_sanitize_callback( $key ) { - $callbacks = array( - 'fetch_interval' => 'sanitize_text_field', - 'articles_count' => 'absint', - 'search_keywords' => 'sanitize_text_field', - 'rss_enabled' => 'absint', - 'rss_feeds' => 'sanitize_textarea_field', - 'newsapi_enabled' => 'absint', - 'newsapi_key' => function ( $value ) { return $this->sanitize_api_key( $value, 'peptide_news_newsapi_key' ); }, - 'article_retention' => 'absint', - 'analytics_retention' => 'absint', - 'anonymize_ip' => 'absint', - 'filter_enabled' => 'absint', - 'filter_sensitivity' => 'sanitize_text_field', - 'filter_llm_enabled' => 'absint', - 'filter_llm_model' => array( $this, 'sanitize_model_id' ), - 'filter_title_keywords' => 'sanitize_textarea_field', - 'filter_body_keywords' => 'sanitize_textarea_field', - 'filter_blocked_domains' => 'sanitize_textarea_field', - 'llm_enabled' => 'absint', - 'openrouter_api_key' => function ( $value ) { return $this->sanitize_api_key( $value, 'peptide_news_openrouter_api_key' ); }, - 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), - 'llm_summary_model' => array( $this, 'sanitize_model_id' ), - 'llm_max_articles' => 'absint', - ); - - return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; - } - - /** - * Sanitize and validate an OpenRouter model ID. - * - * @param string $value Raw input. - * @return string Sanitized model ID or default. - */ - public function sanitize_model_id( $value ) { - $value = sanitize_text_field( $value ); - if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { - add_settings_error( - 'peptide_news_settings_group', - 'invalid_model', - sprintf( - /* translators: %s: model ID */ - __( 'Invalid model ID format: %s. Expected format: provider/model-name', 'peptide-news' ), - esc_html( $value ) - ), - 'error' - ); - return 'google/gemini-2.0-flash-001'; - } - return $value; - } - - /** - * Sanitize API keys. If a masked key is submitted (••••••••XXXX format), - * keep the existing key. Otherwise, store the new full key. - * - * @param string $value Raw input. - * @return string Stored API key (full value or existing). - */ - public function sanitize_api_key( $value, $option_name ) { - $value = sanitize_text_field( $value ); - if ( empty( $value ) ) { - return ''; - } - - // If masked format detected (starts with bullets), keep existing stored value. - if ( mb_strpos( $value, '••••••' ) === 0 ) { - return get_option( $option_name, '' ); - } - - return $value; - } - - /** - * Get a masked version of an API key showing only the last 4 characters. - * - * @param string $key Full API key. - * @return string Masked key (e.g., ••••••••XXXX). - */ - private function mask_api_key( $key ) { - if ( empty( $key ) ) { - return ''; - } - $len = strlen( $key ); - if ( $len <= 4 ) { - return str_repeat( '•', $len ); - } - return str_repeat( '•', $len - 4 ) . substr( $key, -4 ); - } - - // --- Field renderers --- - - public function render_fetch_interval_field() { - $value = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); - $options = array( - 'every_fifteen_minutes' => __( 'Every 15 minutes', 'peptide-news' ), - 'every_thirty_minutes' => __( 'Every 30 minutes', 'peptide-news' ), - 'hourly' => __( 'Hourly', 'peptide-news' ), - 'every_four_hours' => __( 'Every 4 hours', 'peptide-news' ), - 'every_six_hours' => __( 'Every 6 hours', 'peptide-news' ), - 'twicedaily' => __( 'Twice daily', 'peptide-news' ), - 'daily' => __( 'Daily', 'peptide-news' ), - ); - - echo ''; - echo '

' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '

'; - } - - public function render_articles_count_field() { - $value = get_option( 'peptide_news_articles_count', 10 ); - printf( - '', - absint( $value ) - ); - echo '

' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '

'; - } - - public function render_search_keywords_field() { - $value = get_option( 'peptide_news_search_keywords', '' ); - printf( - '', - esc_attr( $value ) - ); - echo '

' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '

'; - } - - public function render_rss_enabled_field() { - $value = get_option( 'peptide_news_rss_enabled', 1 ); - printf( - '', - checked( $value, 1, false ), - esc_html__( 'Fetch articles from RSS feeds', 'peptide-news' ) - ); - } - - public function render_rss_feeds_field() { - $value = get_option( 'peptide_news_rss_feeds', '' ); - printf( - '', - esc_textarea( $value ) - ); - } - - public function render_newsapi_enabled_field() { - $value = get_option( 'peptide_news_newsapi_enabled', 0 ); - printf( - '', - checked( $value, 1, false ), - esc_html__( 'Fetch articles from NewsAPI.org', 'peptide-news' ) - ); - } - - public function render_newsapi_key_field() { - $value = get_option( 'peptide_news_newsapi_key', '' ); - $display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : ''; - printf( - '', - esc_attr( $display_value ), - esc_attr__( 'Enter new API key to change', 'peptide-news' ) - ); - } - - public function render_article_retention_field() { - $value = get_option( 'peptide_news_article_retention', 90 ); - printf( - '', - absint( $value ) - ); - echo '

' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '

'; - } - - public function render_retention_field() { - $value = get_option( 'peptide_news_analytics_retention', 365 ); - printf( - '', - absint( $value ) - ); - echo '

' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '

'; - } - - public function render_anonymize_ip_field() { - $value = get_option( 'peptide_news_anonymize_ip', 1 ); - printf( - '', - checked( $value, 1, false ), - esc_html__( 'Anonymize IP addresses for GDPR compliance', 'peptide-news' ) - ); - } - - // --- Content Filter field renderers --- - - public function render_filter_enabled_field() { - $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' ) - ); - } - - public function render_filter_sensitivity_field() { - $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 function render_filter_llm_enabled_field() { - $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 function render_filter_llm_model_field() { - $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 function render_filter_title_keywords_field() { - $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 function render_filter_body_keywords_field() { - $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 function render_filter_blocked_domains_field() { - $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' ) . '

'; - } - - // --- AI / LLM field renderers --- - - public function render_llm_enabled_field() { - $value = get_option( 'peptide_news_llm_enabled', 0 ); - printf( - '', - checked( $value, 1, false ), - esc_html__( 'Analyze articles with AI during each fetch cycle', 'peptide-news' ) - ); - } - - public function render_openrouter_key_field() { - $value = get_option( 'peptide_news_openrouter_api_key', '' ); - $display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : ''; - printf( - '', - esc_attr( $display_value ), - esc_attr__( 'Enter new API key to change', 'peptide-news' ) - ); - echo '

' . wp_kses( - __( 'Get an API key at openrouter.ai/keys.', 'peptide-news' ), - array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) ) - ) . '

'; - } - - public function render_llm_keywords_model_field() { - $value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); - printf( - '', - esc_attr( $value ) - ); - echo '

' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '

'; - } - - public function render_llm_summary_model_field() { - $value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); - printf( - '', - esc_attr( $value ) - ); - echo '

' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '

'; - } - - public function render_llm_max_articles_field() { - $value = get_option( 'peptide_news_llm_max_articles', 10 ); - printf( - '', - absint( $value ) - ); - echo '

' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '

'; - } - - // --- Page renderers --- - - public function render_settings_page() { - if ( ! current_user_can( 'manage_options' ) ) { - return; - } - - // Handle manual fetch trigger. - if ( isset( $_POST['peptide_news_fetch_now'] ) ) { - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); - } - check_admin_referer( 'peptide_news_fetch_now_action' ); - $fetcher = new Peptide_News_Fetcher(); - $fetcher->fetch_all_sources(); - echo '

' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '

'; - } - - echo '
'; - echo '

' . esc_html( get_admin_page_title() ) . '

'; - - // Fetch Now button. - echo '
'; - wp_nonce_field( 'peptide_news_fetch_now_action' ); - submit_button( __( 'Fetch Articles Now', 'peptide-news' ), 'secondary', 'peptide_news_fetch_now', false ); - echo '
'; - - // Backfill Sources button (fixes "news.google.com" source labels). - echo ''; - echo ''; - - // Generate AI Summaries button. - echo '
'; - echo ''; - echo ''; - - ?> - - 0 ) { - $extra_info .= sprintf( ' | %s: %d', - esc_html__( 'Filtered out', 'peptide-news' ), - $last_fetch['filtered_out'] - ); - } - if ( isset( $last_fetch['ai_processed'] ) ) { - $extra_info .= sprintf( ' | %s: %d', - esc_html__( 'AI analyzed', 'peptide-news' ), - $last_fetch['ai_processed'] - ); - } - printf( - '

%s: %s | %s: %d | %s: %d%s

', - esc_html__( 'Last fetch', 'peptide-news' ), - esc_html( $last_fetch['time'] ), - esc_html__( 'Found', 'peptide-news' ), - $last_fetch['found'], - esc_html__( 'New stored', 'peptide-news' ), - $last_fetch['new_stored'], - $extra_info - ); - } - - // Settings form. - echo '
'; - settings_fields( 'peptide_news_settings_group' ); - do_settings_sections( 'peptide-news-settings' ); - submit_button(); - echo '
'; - - // Shortcode usage info. - echo '

' . esc_html__( 'Usage', 'peptide-news' ) . '

'; - echo '

' . esc_html__( 'Shortcode:', 'peptide-news' ) . ' [peptide_news] ' . - esc_html__( 'or', 'peptide-news' ) . ' [peptide_news count="5"]

'; - 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 ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo '
' . esc_html__( 'Time', 'peptide-news' ) . '' . esc_html__( 'Level', 'peptide-news' ) . '' . esc_html__( 'Context', 'peptide-news' ) . '' . esc_html__( 'Message', 'peptide-news' ) . '
' . esc_html__( 'Loading...', 'peptide-news' ) . '
'; - echo '
'; - - // Pagination. - echo '
'; - - ?> - - '; - } - - public function render_dashboard_page() { - if ( ! current_user_can( 'manage_options' ) ) { - return; - } - - // Handle CSV export before any HTML output. - if ( isset( $_GET['export'] ) && 'csv' === $_GET['export'] ) { - check_admin_referer( 'peptide_news_export_csv' ); - $this->export_csv(); - return; - } - - include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php'; - } - - /** - * Stream a CSV export of click analytics data. - */ - private function export_csv() { - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) ); - } - - $raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : ''; - $raw_end = isset( $_GET['end_date'] ) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : ''; - $start_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_start ) ? $raw_start : gmdate( 'Y-m-d', strtotime( '-30 days' ) ); - $end_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_end ) ? $raw_end : gmdate( 'Y-m-d' ); - - $data = Peptide_News_Analytics::export_clicks_csv( $start_date, $end_date ); - - $filename = 'peptide-news-clicks-' . $start_date . '-to-' . $end_date . '.csv'; - - header( 'Content-Type: text/csv; charset=utf-8' ); - header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); - header( 'Pragma: no-cache' ); - header( 'Expires: 0' ); - - $output = fopen( 'php://output', 'w' ); - - if ( ! empty( $data ) ) { - fputcsv( $output, array_keys( $data[0] ) ); - foreach ( $data as $row ) { - fputcsv( $output, $row ); - } - } else { - fputcsv( $output, array( 'No data for selected period' ) ); - } - - fclose( $output ); - exit; - } - - public function render_articles_page() { - if ( ! current_user_can( 'manage_options' ) ) { - return; - } - include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php'; - } + /** @var string */ + private $plugin_name; + + /** @var string */ + private $version; + + public function __construct( $plugin_name, $version ) { + $this->plugin_name = $plugin_name; + $this->version = $version; + } + + /** + * Enqueue admin CSS. + */ + public function enqueue_styles( $hook ) { + if ( strpos( $hook, 'peptide-news' ) === false ) { + return; + } + wp_enqueue_style( + $this->plugin_name . '-admin', + PEPTIDE_NEWS_PLUGIN_URL . 'admin/css/admin-style.css', + array(), + $this->version, + 'all' + ); + } + + /** + * Enqueue admin JS. + */ + public function enqueue_scripts( $hook ) { + if ( strpos( $hook, 'peptide-news' ) === false ) { + return; + } + + // Chart.js for analytics dashboard. + wp_enqueue_script( + 'chartjs', + plugins_url( 'vendor/chartjs/chart.umd.min.js', __FILE__ ), + array(), + '4.4.0', + true + ); + + wp_enqueue_script( + $this->plugin_name . '-admin', + PEPTIDE_NEWS_PLUGIN_URL . 'admin/js/admin-script.js', + array( 'jquery', 'chartjs' ), + $this->version, + true + ); + + wp_localize_script( $this->plugin_name . '-admin', 'peptideNewsAdmin', array( + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'rest_url' => rest_url( 'peptide-news/v1/' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'admin_nonce' => wp_create_nonce( 'peptide_news_admin' ), + ) ); + } + + /** + * Add admin menu pages. + */ + public function add_admin_menu() { + // Main menu — Analytics Dashboard. + add_menu_page( + __( 'Peptide News', 'peptide-news' ), + __( 'Peptide News', 'peptide-news' ), + 'manage_options', + 'peptide-news-dashboard', + array( $this, 'render_dashboard_page' ), + 'dashicons-rss', + 30 + ); + + // Sub-menu — Settings. + add_submenu_page( + 'peptide-news-dashboard', + __( 'Settings', 'peptide-news' ), + __( 'Settings', 'peptide-news' ), + 'manage_options', + 'peptide-news-settings', + array( $this, 'render_settings_page' ) + ); + + // Sub-menu — Articles. + add_submenu_page( + 'peptide-news-dashboard', + __( 'Articles', 'peptide-news' ), + __( 'Articles', 'peptide-news' ), + 'manage_options', + 'peptide-news-articles', + array( $this, 'render_articles_page' ) + ); + } + + /** + * Register all settings. + */ + public function register_settings() { + + // --- General section --- + add_settings_section( + 'peptide_news_general', + __( 'General Settings', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Configure how and when peptide news articles are fetched.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'fetch_interval', __( 'Fetch Interval', 'peptide-news' ), 'render_fetch_interval_field', 'peptide_news_general' ); + $this->add_setting( 'articles_count', __( 'Articles to Display', 'peptide-news' ), 'render_articles_count_field', 'peptide_news_general' ); + $this->add_setting( 'search_keywords', __( 'Search Keywords', 'peptide-news' ), 'render_search_keywords_field', 'peptide_news_general' ); + + // --- RSS section --- + add_settings_section( + 'peptide_news_rss', + __( 'RSS Feed Sources', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Add RSS feed URLs, one per line.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'rss_enabled', __( 'Enable RSS Feeds', 'peptide-news' ), 'render_rss_enabled_field', 'peptide_news_rss' ); + $this->add_setting( 'rss_feeds', __( 'RSS Feed URLs', 'peptide-news' ), 'render_rss_feeds_field', 'peptide_news_rss' ); + + // --- NewsAPI section --- + add_settings_section( + 'peptide_news_newsapi', + __( 'NewsAPI.org', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Optional. Get a free API key at newsapi.org.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'newsapi_enabled', __( 'Enable NewsAPI', 'peptide-news' ), 'render_newsapi_enabled_field', 'peptide_news_newsapi' ); + $this->add_setting( 'newsapi_key', __( 'API Key', 'peptide-news' ), 'render_newsapi_key_field', 'peptide_news_newsapi' ); + + // --- Analytics section --- + add_settings_section( + 'peptide_news_analytics_section', + __( 'Analytics', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Configure click tracking and data retention.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'article_retention', __( 'Article Retention (days)', 'peptide-news' ), 'render_article_retention_field', 'peptide_news_analytics_section' ); + $this->add_setting( 'analytics_retention', __( 'Analytics Data Retention (days)', 'peptide-news' ), 'render_retention_field', 'peptide_news_analytics_section' ); + $this->add_setting( 'anonymize_ip', __( 'Anonymize IPs', 'peptide-news' ), 'render_anonymize_ip_field', 'peptide_news_analytics_section' ); + + // --- Content Filter section --- + add_settings_section( + 'peptide_news_filter_section', + __( 'Content Filter', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Filter out ads, press releases, and promotional content during fetch. Articles that match the rules are discarded before being saved.', 'peptide-news' ) . '

'; + $last_run = get_option( 'peptide_news_filter_last_run' ); + if ( $last_run && is_array( $last_run ) ) { + printf( + '

%s: %s | %s: %d | %s: %d | %s: %d | %s: %d

', + esc_html__( 'Last filter run', 'peptide-news' ), + esc_html( $last_run['time'] ), + esc_html__( 'Evaluated', 'peptide-news' ), + absint( $last_run['total'] ), + esc_html__( 'Removed', 'peptide-news' ), + absint( $last_run['removed'] ), + esc_html__( 'LLM checked', 'peptide-news' ), + absint( $last_run['llm_checked'] ), + esc_html__( 'Passed', 'peptide-news' ), + absint( $last_run['passed'] ) + ); + } + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'filter_enabled', __( 'Enable Content Filter', 'peptide-news' ), 'render_filter_enabled_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_sensitivity', __( 'Filter Sensitivity', 'peptide-news' ), 'render_filter_sensitivity_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_llm_enabled', __( 'LLM Classification', 'peptide-news' ), 'render_filter_llm_enabled_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_llm_model', __( 'Filter LLM Model', 'peptide-news' ), 'render_filter_llm_model_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_title_keywords', __( 'Title Keywords', 'peptide-news' ), 'render_filter_title_keywords_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_body_keywords', __( 'Body Keywords', 'peptide-news' ), 'render_filter_body_keywords_field', 'peptide_news_filter_section' ); + $this->add_setting( 'filter_blocked_domains', __( 'Blocked Domains', 'peptide-news' ), 'render_filter_blocked_domains_field', 'peptide_news_filter_section' ); + + // --- AI / LLM section --- + add_settings_section( + 'peptide_news_llm_section', + __( 'AI Analysis (OpenRouter)', 'peptide-news' ), + function () { + echo '

' . esc_html__( 'Configure LLM-powered keyword extraction and article summarization via OpenRouter.', 'peptide-news' ) . '

'; + }, + 'peptide-news-settings' + ); + + $this->add_setting( 'llm_enabled', __( 'Enable AI Analysis', 'peptide-news' ), 'render_llm_enabled_field', 'peptide_news_llm_section' ); + $this->add_setting( 'openrouter_api_key', __( 'OpenRouter API Key', 'peptide-news' ), 'render_openrouter_key_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_keywords_model', __( 'Keywords Model', 'peptide-news' ), 'render_llm_keywords_model_field', 'peptide_news_llm_section' ); + $this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' ); + + $this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' ); + } + + /** + * Helper: register a single setting with a field callback. + */ + private function add_setting( $key, $label, $callback, $section ) { + $option_name = "peptide_news_{$key}"; + + $sanitize_cb = $this->get_sanitize_callback( $key ); + + register_setting( 'peptide_news_settings_group', $option_name, array( + 'sanitize_callback' => $sanitize_cb, + ) ); + + add_settings_field( + $option_name, + $label, + array( $this, $callback ), + 'peptide-news-settings', + $section + ); + } + + /** + * Return the appropriate sanitize callback for a given setting key. + * + * @param string $key Setting key (without prefix). + * @return callable + */ + private function get_sanitize_callback( $key ) { + $callbacks = array( + 'fetch_interval' => 'sanitize_text_field', + 'articles_count' => 'absint', + 'search_keywords' => 'sanitize_text_field', + 'rss_enabled' => 'absint', + 'rss_feeds' => 'sanitize_textarea_field', + 'newsapi_enabled' => 'absint', + 'newsapi_key' => function ( $value ) { + return $this->sanitize_api_key( $value, 'peptide_news_newsapi_key' ); + }, + 'article_retention' => 'absint', + 'analytics_retention' => 'absint', + 'anonymize_ip' => 'absint', + 'filter_enabled' => 'absint', + 'filter_sensitivity' => 'sanitize_text_field', + 'filter_llm_enabled' => 'absint', + 'filter_llm_model' => array( $this, 'sanitize_model_id' ), + 'filter_title_keywords' => 'sanitize_textarea_field', + 'filter_body_keywords' => 'sanitize_textarea_field', + 'filter_blocked_domains' => 'sanitize_textarea_field', + 'llm_enabled' => 'absint', + 'openrouter_api_key' => function ( $value ) { + return $this->sanitize_api_key( $value, 'peptide_news_openrouter_api_key' ); + }, + 'llm_keywords_model' => array( $this, 'sanitize_model_id' ), + 'llm_summary_model' => array( $this, 'sanitize_model_id' ), + 'llm_max_articles' => 'absint', + ); + + return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field'; + } + + /** + * Sanitize and validate an OpenRouter model ID. + * + * @param string $value Raw input. + * @return string Sanitized model ID or default. + */ + public function sanitize_model_id( $value ) { + $value = sanitize_text_field( $value ); + if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) { + add_settings_error( + 'peptide_news_settings_group', + 'invalid_model', + sprintf( + /* translators: %s: model ID */ + __( 'Invalid model ID format: %s. Expected format: provider/model-name', 'peptide-news' ), + esc_html( $value ) + ), + 'error' + ); + return 'google/gemini-2.0-flash-001'; + } + return $value; + } + + /** + * Sanitize API keys. If a masked key is submitted (••••••••XXXX format), + * keep the existing key. Otherwise, store the new full key. + * + * @param string $value Raw input. + * @return string Stored API key (full value or existing). + */ + public function sanitize_api_key( $value, $option_name ) { + $value = sanitize_text_field( $value ); + if ( empty( $value ) ) { + return ''; + } + + // If masked format detected (starts with bullets), keep existing stored value. + if ( mb_strpos( $value, '••••••' ) === 0 ) { + return get_option( $option_name, '' ); + } + + return $value; + } + + /** + * Get a masked version of an API key showing only the last 4 characters. + * + * @param string $key Full API key. + * @return string Masked key (e.g., ••••••••XXXX). + */ + private function mask_api_key( $key ) { + if ( empty( $key ) ) { + return ''; + } + $len = strlen( $key ); + if ( $len <= 4 ) { + return str_repeat( '•', $len ); + } + return str_repeat( '•', $len - 4 ) . substr( $key, -4 ); + } + + // --- Field renderers --- + + public function render_fetch_interval_field() { + $value = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); + $options = array( + 'every_fifteen_minutes' => __( 'Every 15 minutes', 'peptide-news' ), + 'every_thirty_minutes' => __( 'Every 30 minutes', 'peptide-news' ), + 'hourly' => __( 'Hourly', 'peptide-news' ), + 'every_four_hours' => __( 'Every 4 hours', 'peptide-news' ), + 'every_six_hours' => __( 'Every 6 hours', 'peptide-news' ), + 'twicedaily' => __( 'Twice daily', 'peptide-news' ), + 'daily' => __( 'Daily', 'peptide-news' ), + ); + + echo ''; + echo '

' . esc_html__( 'How often to check for new articles. After changing, deactivate and reactivate the plugin.', 'peptide-news' ) . '

'; + } + + public function render_articles_count_field() { + $value = get_option( 'peptide_news_articles_count', 10 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'Number of articles to display in the shortcode/widget.', 'peptide-news' ) . '

'; + } + + public function render_search_keywords_field() { + $value = get_option( 'peptide_news_search_keywords', '' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '

'; + } + + public function render_rss_enabled_field() { + $value = get_option( 'peptide_news_rss_enabled', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Fetch articles from RSS feeds', 'peptide-news' ) + ); + } + + public function render_rss_feeds_field() { + $value = get_option( 'peptide_news_rss_feeds', '' ); + printf( + '', + esc_textarea( $value ) + ); + } + + public function render_newsapi_enabled_field() { + $value = get_option( 'peptide_news_newsapi_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Fetch articles from NewsAPI.org', 'peptide-news' ) + ); + } + + public function render_newsapi_key_field() { + $value = get_option( 'peptide_news_newsapi_key', '' ); + $display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : ''; + printf( + '', + esc_attr( $display_value ), + esc_attr__( 'Enter new API key to change', 'peptide-news' ) + ); + } + + public function render_article_retention_field() { + $value = get_option( 'peptide_news_article_retention', 90 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'How long to keep fetched articles before deactivating them.', 'peptide-news' ) . '

'; + } + + public function render_retention_field() { + $value = get_option( 'peptide_news_analytics_retention', 365 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'How long to keep click analytics data.', 'peptide-news' ) . '

'; + } + + public function render_anonymize_ip_field() { + $value = get_option( 'peptide_news_anonymize_ip', 1 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Anonymize IP addresses for GDPR compliance', 'peptide-news' ) + ); + } + + // --- Content Filter field renderers --- + + public function render_filter_enabled_field() { + $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' ) + ); + } + + public function render_filter_sensitivity_field() { + $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 function render_filter_llm_enabled_field() { + $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 function render_filter_llm_model_field() { + $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 function render_filter_title_keywords_field() { + $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 function render_filter_body_keywords_field() { + $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 function render_filter_blocked_domains_field() { + $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' ) . '

'; + } + + // --- AI / LLM field renderers --- + + public function render_llm_enabled_field() { + $value = get_option( 'peptide_news_llm_enabled', 0 ); + printf( + '', + checked( $value, 1, false ), + esc_html__( 'Analyze articles with AI during each fetch cycle', 'peptide-news' ) + ); + } + + public function render_openrouter_key_field() { + $value = get_option( 'peptide_news_openrouter_api_key', '' ); + $display_value = ! empty( $value ) ? $this->mask_api_key( $value ) : ''; + printf( + '', + esc_attr( $display_value ), + esc_attr__( 'Enter new API key to change', 'peptide-news' ) + ); + echo '

' . wp_kses( + __( 'Get an API key at openrouter.ai/keys.', 'peptide-news' ), + array( + 'a' => array( + 'href' => array(), + 'target' => array(), + 'rel' => array(), + ), + ) + ) . '

'; + } + + public function render_llm_keywords_model_field() { + $value = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'OpenRouter model ID for keyword extraction (e.g., google/gemini-2.0-flash-001, openai/gpt-4o-mini).', 'peptide-news' ) . '

'; + } + + public function render_llm_summary_model_field() { + $value = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); + printf( + '', + esc_attr( $value ) + ); + echo '

' . esc_html__( 'OpenRouter model ID for article summarization (e.g., google/gemini-2.0-flash-001, anthropic/claude-3.5-sonnet).', 'peptide-news' ) . '

'; + } + + public function render_llm_max_articles_field() { + $value = get_option( 'peptide_news_llm_max_articles', 10 ); + printf( + '', + absint( $value ) + ); + echo '

' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '

'; + } + + // --- Page renderers --- + + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle manual fetch trigger. + if ( isset( $_POST['peptide_news_fetch_now'] ) ) { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); + } + check_admin_referer( 'peptide_news_fetch_now_action' ); + $fetcher = new Peptide_News_Fetcher(); + $fetcher->fetch_all_sources(); + echo '

' . esc_html__( 'Fetch completed!', 'peptide-news' ) . '

'; + } + + echo '
'; + echo '

' . esc_html( get_admin_page_title() ) . '

'; + + // Fetch Now button. + echo '
'; + wp_nonce_field( 'peptide_news_fetch_now_action' ); + submit_button( __( 'Fetch Articles Now', 'peptide-news' ), 'secondary', 'peptide_news_fetch_now', false ); + echo '
'; + + // Backfill Sources button (fixes "news.google.com" source labels). + echo ''; + echo ''; + + // Generate AI Summaries button. + echo '
'; + echo ''; + echo ''; + + ?> + + 0 ) { + $extra_info .= sprintf( + ' | %s: %d', + esc_html__( 'Filtered out', 'peptide-news' ), + $last_fetch['filtered_out'] + ); + } + if ( isset( $last_fetch['ai_processed'] ) ) { + $extra_info .= sprintf( + ' | %s: %d', + esc_html__( 'AI analyzed', 'peptide-news' ), + $last_fetch['ai_processed'] + ); + } + printf( + '

%s: %s | %s: %d | %s: %d%s

', + esc_html__( 'Last fetch', 'peptide-news' ), + esc_html( $last_fetch['time'] ), + esc_html__( 'Found', 'peptide-news' ), + intval( $last_fetch['found'] ), + esc_html__( 'New stored', 'peptide-news' ), + intval( $last_fetch['new_stored'] ), + esc_html( $extra_info ) + ); + } + + // Settings form. + echo '
'; + settings_fields( 'peptide_news_settings_group' ); + do_settings_sections( 'peptide-news-settings' ); + submit_button(); + echo '
'; + + // Shortcode usage info. + echo '

' . esc_html__( 'Usage', 'peptide-news' ) . '

'; + echo '

' . esc_html__( 'Shortcode:', 'peptide-news' ) . ' [peptide_news] ' . + esc_html__( 'or', 'peptide-news' ) . ' [peptide_news count="5"]

'; + 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 ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
' . esc_html__( 'Time', 'peptide-news' ) . '' . esc_html__( 'Level', 'peptide-news' ) . '' . esc_html__( 'Context', 'peptide-news' ) . '' . esc_html__( 'Message', 'peptide-news' ) . '
' . esc_html__( 'Loading...', 'peptide-news' ) . '
'; + echo '
'; + + // Pagination. + echo '
'; + + ?> + + '; + } + + public function render_dashboard_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + // Handle CSV export before any HTML output. + if ( isset( $_GET['export'] ) && 'csv' === $_GET['export'] ) { + check_admin_referer( 'peptide_news_export_csv' ); + $this->export_csv(); + return; + } + + include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/dashboard.php'; + } + + /** + * Stream a CSV export of click analytics data. + */ + private function export_csv() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) ); + } + + $raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : ''; + $raw_end = isset( $_GET['end_date'] ) ? sanitize_text_field( wp_unslash( $_GET['end_date'] ) ) : ''; + $start_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_start ) ? $raw_start : gmdate( 'Y-m-d', strtotime( '-30 days' ) ); + $end_date = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw_end ) ? $raw_end : gmdate( 'Y-m-d' ); + + $data = Peptide_News_Analytics::export_clicks_csv( $start_date, $end_date ); + + $filename = 'peptide-news-clicks-' . $start_date . '-to-' . $end_date . '.csv'; + + header( 'Content-Type: text/csv; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + $output = fopen( 'php://output', 'w' ); + + if ( ! empty( $data ) ) { + fputcsv( $output, array_keys( $data[0] ) ); + foreach ( $data as $row ) { + fputcsv( $output, $row ); + } + } else { + fputcsv( $output, array( 'No data for selected period' ) ); + } + + fclose( $output ); + exit; + } + + public function render_articles_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php'; + } } diff --git a/admin/partials/dashboard.php b/admin/partials/dashboard.php index 5311bdf..b64f797 100644 --- a/admin/partials/dashboard.php +++ b/admin/partials/dashboard.php @@ -6,7 +6,7 @@ */ if ( ! defined( 'ABSPATH' ) ) { - exit; + exit; } $raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : ''; @@ -27,126 +27,126 @@ $total_articles = count( $top_articles ); ?>
-

+

- -
- - - - - - - -
+ +
+ + + + + + + +
- -
-
-

-

-
-
-

-

-
-
-

-

-
-
-

0 ? esc_html( round( $total_clicks / $total_articles, 1 ) ) : '0'; ?>

-

-
-
+ +
+
+

+

+
+
+

+

+
+
+

+

+
+
+

0 ? esc_html( round( $total_clicks / $total_articles, 1 ) ) : '0'; ?>

+

+
+
- -
-
-

- -
-
+ +
+
+

+ +
+
-
-
-

- -
-
-

- -
-
+
+
+

+ +
+
+

+ +
+
- -
-

- - - - - - - - - - - - - - - - $article ) : ?> - - - - - - - - - - - -
#
title ); ?>source ); ?>categories ); ?>total_clicks ) ); ?>total_unique ) ); ?>
-
+ +
+

+ + + + + + + + + + + + + + + + $article ) : ?> + + + + + + + + + + + +
#
title ); ?>source ); ?>categories ); ?>total_clicks ) ); ?>total_unique ) ); ?>
+
- -
-

- - - - - - - - - - - - - - $topic ) : ?> - - - - - - - - - -
#
-
+ +
+

+ + + + + + + + + + + + + + $topic ) : ?> + + + + + + + + + +
#
+
diff --git a/includes/class-peptide-news-activator.php b/includes/class-peptide-news-activator.php index a05d8a4..8fd223d 100644 --- a/includes/class-peptide-news-activator.php +++ b/includes/class-peptide-news-activator.php @@ -8,180 +8,180 @@ */ class Peptide_News_Activator { - /** - * Create custom tables and schedule cron. - */ - public static function activate() { - self::create_tables(); - self::set_default_options(); - self::schedule_cron(); + /** + * Create custom tables and schedule cron. + */ + public static function activate() { + 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(); - } - } + // 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; + /** + * Create the articles and analytics database tables. + */ + private static function create_tables() { + global $wpdb; - $charset_collate = $wpdb->get_charset_collate(); + $charset_collate = $wpdb->get_charset_collate(); - // 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};"; + // 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};"; + // 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};"; + // 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'; + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta( $sql_articles ); - dbDelta( $sql_clicks ); - dbDelta( $sql_daily ); + dbDelta( $sql_articles ); + dbDelta( $sql_clicks ); + dbDelta( $sql_daily ); - // Add foreign key constraints if they don't exist. - self::add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ); + // Add foreign key constraints if they don't exist. + self::add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ); - update_option( 'peptide_news_db_version', PEPTIDE_NEWS_VERSION ); - } + 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, - ); + /** + * 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 ); - } - } - } + 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' ); + /** + * 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' ); - } - } + if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) { + wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' ); + } + } - /** - * Add foreign key constraints to tables. - */ - private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { - global $wpdb; + /** + * Add foreign key constraints to tables. + */ + private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { + global $wpdb; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $existing_clicks_fk = $wpdb->get_var( - $wpdb->prepare( - "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1", - $clicks_table - ) - ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $existing_clicks_fk = $wpdb->get_var( + $wpdb->prepare( + "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1", + $clicks_table + ) + ); - if ( empty( $existing_clicks_fk ) ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $wpdb->query( - "ALTER TABLE `{$clicks_table}` ADD CONSTRAINT `fk_clicks_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" - ); - } + if ( empty( $existing_clicks_fk ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + "ALTER TABLE `{$clicks_table}` ADD CONSTRAINT `fk_clicks_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" + ); + } - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $existing_stats_fk = $wpdb->get_var( - $wpdb->prepare( - "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1", - $daily_table - ) - ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $existing_stats_fk = $wpdb->get_var( + $wpdb->prepare( + "SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'article_id' AND REFERENCED_TABLE_NAME IS NOT NULL LIMIT 1", + $daily_table + ) + ); - if ( empty( $existing_stats_fk ) ) { - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $wpdb->query( - "ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" - ); - } - } + if ( empty( $existing_stats_fk ) ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query( + "ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" + ); + } + } } diff --git a/includes/class-peptide-news-analytics.php b/includes/class-peptide-news-analytics.php index e68467c..068edd4 100644 --- a/includes/class-peptide-news-analytics.php +++ b/includes/class-peptide-news-analytics.php @@ -9,342 +9,342 @@ */ class Peptide_News_Analytics { - /** - * Record a click event for an article. - * - * @param int $article_id - * @param array $meta Additional context (IP, UA, referrer, etc.). - * @return bool - */ - public static function record_click( $article_id, $meta = array() ) { - global $wpdb; + /** + * Record a click event for an article. + * + * @param int $article_id + * @param array $meta Additional context (IP, UA, referrer, etc.). + * @return bool + */ + public static function record_click( $article_id, $meta = array() ) { + global $wpdb; - $table = $wpdb->prefix . 'peptide_news_clicks'; + $table = $wpdb->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; - } + // Skip recording if no valid IP is available. + if ( null === $ip ) { + return false; + } - // Optionally anonymize IP (GDPR-friendly). - if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) { - $ip = self::anonymize_ip( $ip ); - } + // Optionally anonymize IP (GDPR-friendly). + if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) { + $ip = self::anonymize_ip( $ip ); + } - $data = array( - 'article_id' => absint( $article_id ), - 'clicked_at' => current_time( 'mysql' ), - 'user_ip' => sanitize_text_field( $ip ), - 'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ), - 'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ), - 'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ), - 'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ), - 'user_id' => is_user_logged_in() ? get_current_user_id() : null, - 'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ), - ); + $data = array( + 'article_id' => absint( $article_id ), + 'clicked_at' => current_time( 'mysql' ), + 'user_ip' => sanitize_text_field( $ip ), + 'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ), + 'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ), + 'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ), + 'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ), + 'user_id' => is_user_logged_in() ? get_current_user_id() : null, + 'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ), + ); - $result = $wpdb->insert( $table, $data ); + $result = $wpdb->insert( $table, $data ); - // Update daily aggregate. - if ( false !== $result ) { - self::update_daily_stats( $article_id, $ip ); - } + // Update daily aggregate. + if ( false !== $result ) { + self::update_daily_stats( $article_id, $ip ); + } - return false !== $result; - } + return false !== $result; + } - /** - * Update daily aggregated stats. - * - * @param int $article_id - * @param string $ip Anonymized IP for unique visitor counting. - */ - private static function update_daily_stats( $article_id, $ip ) { - global $wpdb; + /** + * Update daily aggregated stats. + * + * @param int $article_id + * @param string $ip Anonymized IP for unique visitor counting. + */ + private static function update_daily_stats( $article_id, $ip ) { + global $wpdb; - $table = $wpdb->prefix . 'peptide_news_daily_stats'; - $today = current_time( 'Y-m-d' ); - $clicks_tbl = $wpdb->prefix . 'peptide_news_clicks'; + $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 - ) ); + // Upsert daily click count. + $existing = $wpdb->get_row( $wpdb->prepare( + "SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s", + $article_id, + $today + ) ); - // Count unique IPs for today. - $unique = $wpdb->get_var( $wpdb->prepare( - "SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s", - $article_id, - $today - ) ); + // Count unique IPs for today. + $unique = $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s", + $article_id, + $today + ) ); - if ( $existing ) { - $wpdb->update( - $table, - array( - 'click_count' => $existing->click_count + 1, - 'unique_visitors' => (int) $unique, - ), - array( 'id' => $existing->id ), - array( '%d', '%d' ), - array( '%d' ) - ); - } else { - $wpdb->insert( - $table, - array( - 'article_id' => $article_id, - 'stat_date' => $today, - 'click_count' => 1, - 'unique_visitors' => 1, - ) - ); - } - } + if ( $existing ) { + $wpdb->update( + $table, + array( + 'click_count' => $existing->click_count + 1, + 'unique_visitors' => (int) $unique, + ), + array( 'id' => $existing->id ), + array( '%d', '%d' ), + array( '%d' ) + ); + } else { + $wpdb->insert( + $table, + array( + 'article_id' => $article_id, + 'stat_date' => $today, + 'click_count' => 1, + 'unique_visitors' => 1, + ) + ); + } + } - /** - * Get top articles by click count within a date range. - * - * @param string $start_date Y-m-d - * @param string $end_date Y-m-d - * @param int $limit - * @return array - */ - public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { - global $wpdb; + /** + * Get top articles by click count within a date range. + * + * @param string $start_date Y-m-d + * @param string $end_date Y-m-d + * @param int $limit + * @return array + */ + public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { + global $wpdb; - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; + $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 - ) ); - } + return $wpdb->get_results( $wpdb->prepare( + "SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at, + SUM(s.click_count) AS total_clicks, + SUM(s.unique_visitors) AS total_unique + FROM {$stats_table} s + INNER JOIN {$articles_table} a ON a.id = s.article_id + WHERE s.stat_date BETWEEN %s AND %s + GROUP BY a.id + ORDER BY total_clicks DESC + LIMIT %d", + $start_date, + $end_date, + $limit + ) ); + } - /** - * Get click trends over time (daily totals). - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function get_click_trends( $start_date, $end_date ) { - global $wpdb; + /** + * Get click trends over time (daily totals). + * + * @param string $start_date + * @param string $end_date + * @return array + */ + public static function get_click_trends( $start_date, $end_date ) { + global $wpdb; - $table = $wpdb->prefix . 'peptide_news_daily_stats'; + $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 - ) ); - } + 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( $start_date, $end_date, $limit = 20 ) { - global $wpdb; + /** + * 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( $start_date, $end_date, $limit = 20 ) { + global $wpdb; - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; + $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 - ) ); + // 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; - } - } + // 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']; - } ); + // Sort by clicks. + usort( $topics, function ( $a, $b ) { + return $b['total_clicks'] - $a['total_clicks']; + } ); - return array_slice( $topics, 0, $limit ); - } + 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( $start_date, $end_date ) { - global $wpdb; + /** + * Get device breakdown stats. + * + * @param string $start_date + * @param string $end_date + * @return array + */ + public static function get_device_breakdown( $start_date, $end_date ) { + global $wpdb; - $table = $wpdb->prefix . 'peptide_news_clicks'; + $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 - ) ); - } + 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( $start_date, $end_date ) { - global $wpdb; + /** + * Get source performance stats. + * + * @param string $start_date + * @param string $end_date + * @return array + */ + public static function get_source_performance( $start_date, $end_date ) { + global $wpdb; - $stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; - $articles_table = $wpdb->prefix . 'peptide_news_articles'; + $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 - ) ); - } + 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. - * - * @param string $start_date - * @param string $end_date - * @return array - */ - public static function export_clicks_csv( $start_date, $end_date ) { - global $wpdb; + /** + * Export click data as CSV-ready array. + * + * @param string $start_date + * @param string $end_date + * @return array + */ + public static function export_clicks_csv( $start_date, $end_date ) { + 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", - $start_date, - $end_date - ), ARRAY_A ); - } + $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", + $start_date, + $end_date + ), ARRAY_A ); + } - /** - * Get the client IP address. - * - * @return string|null - */ - private static function get_client_ip() { - $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); + /** + * Get the client IP address. + * + * @return string|null + */ + private static function get_client_ip() { + $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); - foreach ( $ip_keys as $key ) { - if ( ! empty( $_SERVER[ $key ] ) ) { - $ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ); - $ip = trim( $ip[0] ); - if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { - return $ip; - } - } - } + foreach ( $ip_keys as $key ) { + if ( ! empty( $_SERVER[ $key ] ) ) { + $ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ); + $ip = trim( $ip[0] ); + if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { + return $ip; + } + } + } - return null; - } + return null; + } - /** - * Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6). - * - * @param string $ip - * @return string - */ - private static function anonymize_ip( $ip ) { - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { - return preg_replace( '/\.\d+$/', '.0', $ip ); - } - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { - return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000'; - } - return '0.0.0.0'; - } + /** + * Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6). + * + * @param string $ip + * @return string + */ + private static function anonymize_ip( $ip ) { + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { + return preg_replace( '/\.\d+$/', '.0', $ip ); + } + if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { + return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000'; + } + return '0.0.0.0'; + } - /** - * Simple device detection from user agent. - * - * @param string $ua - * @return string - */ - private static function detect_device( $ua ) { - $ua = strtolower( $ua ); + /** + * Simple device detection from user agent. + * + * @param string $ua + * @return string + */ + private static function detect_device( $ua ) { + $ua = strtolower( $ua ); - if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { - return 'tablet'; - } - if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) { - return 'mobile'; - } - if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) { - return 'bot'; - } + if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { + return 'tablet'; + } + if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) { + return 'mobile'; + } + if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) { + return 'bot'; + } - return 'desktop'; - } + return 'desktop'; + } } diff --git a/includes/class-peptide-news-content-filter.php b/includes/class-peptide-news-content-filter.php index e6c9357..5cb2910 100644 --- a/includes/class-peptide-news-content-filter.php +++ b/includes/class-peptide-news-content-filter.php @@ -398,8 +398,7 @@ class Peptide_News_Content_Filter { // Use XML-style delimiters to isolate untrusted article data from the prompt. // This mitigates prompt injection by clearly separating instructions from data. - return - "You are a content classifier. Your ONLY task is to classify the article below.\n" . + $prompt = "You are a content classifier. Your ONLY task is to classify the article below.\n" . "Respond with EXACTLY one word: EDITORIAL or PROMOTIONAL.\n" . "EDITORIAL = genuine news, research, or journalism.\n" . "PROMOTIONAL = press releases, ads, sponsored content, product launches, " . @@ -412,6 +411,7 @@ class Peptide_News_Content_Filter { sprintf( "%s\n", $content_trimmed ) . "\n\n" . "Classification (one word only):"; + return $prompt; } /** diff --git a/includes/class-peptide-news-deactivator.php b/includes/class-peptide-news-deactivator.php index 6152cc3..9d58e31 100644 --- a/includes/class-peptide-news-deactivator.php +++ b/includes/class-peptide-news-deactivator.php @@ -8,7 +8,7 @@ */ class Peptide_News_Deactivator { - public static function deactivate() { - wp_clear_scheduled_hook( 'peptide_news_cron_fetch' ); - } + public static function deactivate() { + wp_clear_scheduled_hook( 'peptide_news_cron_fetch' ); + } } diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php index c23a283..381f7ea 100644 --- a/includes/class-peptide-news-fetcher.php +++ b/includes/class-peptide-news-fetcher.php @@ -10,598 +10,598 @@ */ class Peptide_News_Fetcher { - /** - * Add custom WP-Cron schedules. - * - * @param array $schedules Existing schedules. - * @return array - */ - public function add_custom_cron_schedules( $schedules ) { - $schedules['every_fifteen_minutes'] = array( - 'interval' => 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++; - } - } - - // Clear transient caches after storing new articles. - if ( $stored > 0 ) { - $this->clear_article_cache(); - // Invalidate REST API caches. - $this->invalidate_rest_api_cache(); - } - - // 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 ); - - // Process items in batches of 10 to manage memory usage. - $chunk_size = 10; - for ( $offset = 0; $offset < $max_items; $offset += $chunk_size ) { - $items = $feed->get_items( $offset, $chunk_size ); - - if ( empty( $items ) ) { - break; - } - - foreach ( $items as $item ) { - $article = $this->process_rss_item( $item, $feed_url ); - if ( ! empty( $article ) ) { - $articles[] = $article; - } - } - } - } - - return $articles; - } - - /** - * Process a single RSS item into an article array. - * - * @param SimplePie_Item $item - * @param string $feed_url - * @return array Article data or empty array if processing failed. - */ - private function process_rss_item( $item, $feed_url ) { - $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() ); - if ( empty( $article_url ) ) { - return array(); - } - - $source_name = $this->resolve_article_source( $item, $feed_url, $article_url ); - - return 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', - ); - } - - /** - * 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; - - $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_' ) . '%' - ) - ); - } - - /** - * Invalidate REST API response caches matching the articles pattern. - */ - private function invalidate_rest_api_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 ) ) { - $final_url = $raw->url; - } - } - } - - // If we have a final URL, validate that it's not a cross-domain redirect - if ( ! empty( $final_url ) ) { - $original_host = wp_parse_url( $url, PHP_URL_HOST ); - $final_host = wp_parse_url( $final_url, PHP_URL_HOST ); - - // Reject cross-domain redirects - if ( $original_host !== $final_host ) { - return false; - } - - return $final_url; - } - - return 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 ); - } - } + /** + * Add custom WP-Cron schedules. + * + * @param array $schedules Existing schedules. + * @return array + */ + public function add_custom_cron_schedules( $schedules ) { + $schedules['every_fifteen_minutes'] = array( + 'interval' => 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++; + } + } + + // Clear transient caches after storing new articles. + if ( $stored > 0 ) { + $this->clear_article_cache(); + // Invalidate REST API caches. + $this->invalidate_rest_api_cache(); + } + + // 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 ); + + // Process items in batches of 10 to manage memory usage. + $chunk_size = 10; + for ( $offset = 0; $offset < $max_items; $offset += $chunk_size ) { + $items = $feed->get_items( $offset, $chunk_size ); + + if ( empty( $items ) ) { + break; + } + + foreach ( $items as $item ) { + $article = $this->process_rss_item( $item, $feed_url ); + if ( ! empty( $article ) ) { + $articles[] = $article; + } + } + } + } + + return $articles; + } + + /** + * Process a single RSS item into an article array. + * + * @param SimplePie_Item $item + * @param string $feed_url + * @return array Article data or empty array if processing failed. + */ + private function process_rss_item( $item, $feed_url ) { + $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() ); + if ( empty( $article_url ) ) { + return array(); + } + + $source_name = $this->resolve_article_source( $item, $feed_url, $article_url ); + + return 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', + ); + } + + /** + * 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; + + $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_' ) . '%' + ) + ); + } + + /** + * Invalidate REST API response caches matching the articles pattern. + */ + private function invalidate_rest_api_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 ) ) { + $final_url = $raw->url; + } + } + } + + // If we have a final URL, validate that it's not a cross-domain redirect + if ( ! empty( $final_url ) ) { + $original_host = wp_parse_url( $url, PHP_URL_HOST ); + $final_host = wp_parse_url( $final_url, PHP_URL_HOST ); + + // Reject cross-domain redirects + if ( $original_host !== $final_host ) { + return false; + } + + return $final_url; + } + + return 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/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index d47bc95..25058e6 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -9,575 +9,575 @@ */ class Peptide_News_LLM { - /** @var int Maximum retries for rate-limited requests. */ - const MAX_RETRIES = 1; - - /** @var int Maximum elapsed seconds before stopping a batch. */ - const BATCH_TIMEOUT = 120; - - /** - * Get the OpenRouter API URL from options with a hardcoded default. - * - * @return string - */ - private static function get_api_url() { - return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' ); - } - - /** - * Check whether LLM processing is enabled and configured. - * - * @return bool - */ - public static function is_enabled() { - $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); - return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 ); - } - - /** - * Validate an OpenRouter model ID format. - * - * Accepts patterns like: provider/model-name, provider/model-name:version - * - * @param string $model - * @return bool - */ - public static function is_valid_model( $model ) { - return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model ); - } - - /** - * Process a single article: extract keywords and generate a summary. - * - * Skips articles that already have both tags and ai_summary unless $force is true. - * - * @param object $article Database row with id, title, excerpt, content, categories. - * @param bool $force Re-process even if already analyzed. - * @return array Results with 'keywords', 'summary', and 'success' keys. - */ - public static function process_article( $article, $force = false ) { - if ( ! self::is_enabled() ) { - return array( 'keywords' => '', 'summary' => '', 'success' => false ); - } - - $results = array( - 'keywords' => '', - 'summary' => '', - 'success' => false, - 'errors' => array(), - ); - - $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); - $any_success = false; - - // --- Keyword extraction --- - $keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); - $need_keywords = $force || empty( $article->tags ); - - if ( $need_keywords && self::is_valid_model( $keywords_model ) ) { - $keywords_prompt = self::build_keywords_prompt( $article ); - $keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt ); - - 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; - 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; - Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' ); - } - - // --- Summarization --- - $summary_model = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); - $need_summary = $force || empty( $article->ai_summary ); - - if ( $need_summary && self::is_valid_model( $summary_model ) ) { - $summary_prompt = self::build_summary_prompt( $article ); - $summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt ); - - if ( ! is_wp_error( $summary_result ) ) { - // 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; - 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; - Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' ); - } - - $results['success'] = $any_success; - - // --- Persist results --- - if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { - self::save_results( $article->id, $results, $force ); - } - - return $results; - } - - /** - * Process all unanalyzed articles (called after a fetch cycle). - * - * Includes a time-based guard to prevent cron timeouts and - * respects the admin-configured max articles per cycle unless - * $override_limit is true (used by the manual bulk-generate button). - * - * @param int $batch_size Max articles to process per run. - * @param bool $override_limit When true, ignore the admin "max per cycle" cap. - * @return int Number of articles successfully processed. - */ - public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) { - if ( ! self::is_enabled() ) { - return 0; - } - - if ( ! $override_limit ) { - // Respect admin cost-control setting for automated cron runs. - $max_per_cycle = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); - if ( $max_per_cycle < 1 ) { - $max_per_cycle = 10; - } - $batch_size = min( $batch_size, $max_per_cycle ); - } - - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Find articles missing tags OR ai_summary. - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, title, excerpt, content, categories, tags, ai_summary - FROM {$table} - WHERE is_active = 1 - AND ( tags = '' OR ai_summary = '' OR ai_summary IS NULL ) - ORDER BY fetched_at DESC - LIMIT %d", - $batch_size - ) ); - - $processed = 0; - $last_errors = array(); - $start_time = time(); - - foreach ( $articles as $article ) { - // Timeout guard: stop if approaching the limit. - if ( ( time() - $start_time ) >= self::BATCH_TIMEOUT ) { - self::log_error( 'Batch timeout reached after ' . $processed . ' articles. Remaining will process next cycle.' ); - break; - } - - $result = self::process_article( $article ); - - // Only count successful processing. - if ( ! empty( $result['success'] ) ) { - $processed++; - } - - // Track errors for debugging. - if ( ! empty( $result['errors'] ) ) { - $last_errors = $result['errors']; - } - - // Small delay to avoid rate limiting. - if ( next( $articles ) !== false ) { - usleep( 500000 ); // 0.5s between articles. - } - } - - // Clear article cache once per batch (not per article). - if ( $processed > 0 ) { - self::clear_article_cache(); - } - - // Log the processing result. - update_option( 'peptide_news_last_llm_process', array( - 'time' => current_time( 'mysql' ), - 'processed' => $processed, - 'attempted' => count( $articles ), - '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; - } - - /** - * Build the prompt for keyword extraction. - * - * @param object $article - * @return string - */ - private static function build_keywords_prompt( $article ) { - $text = $article->title; - if ( ! empty( $article->excerpt ) ) { - $text .= "\n\n" . $article->excerpt; - } - if ( ! empty( $article->content ) ) { - // Limit content to ~2000 chars to keep token usage reasonable. - $text .= "\n\n" . mb_substr( wp_strip_all_tags( $article->content ), 0, 2000 ); - } - - return "Extract 5-10 relevant keywords or key phrases from this peptide research article. " - . "Return ONLY a comma-separated list of keywords, nothing else. No numbering, no explanations.\n\n" - . "Article:\n" . $text; - } - - /** - * Build the prompt for article summarization. - * - * When the article's stored content is sparse (empty or just the title), - * attempts to fetch the actual page content from the source URL. - * - * @param object $article - * @return string - */ - private static function build_summary_prompt( $article ) { - $title = trim( $article->title ?? '' ); - $excerpt = trim( $article->excerpt ?? '' ); - $content = trim( $article->content ?? '' ); - $content_raw = wp_strip_all_tags( $content ); - - // Determine if we have meaningful content beyond just the title. - $has_real_excerpt = ! empty( $excerpt ) && $excerpt !== $title; - $has_real_content = mb_strlen( $content_raw ) > 100; - - // If content is sparse, try fetching the article's web page. - if ( ! $has_real_content && ! empty( $article->source_url ) ) { - $fetched = self::fetch_article_text( $article->source_url ); - if ( ! empty( $fetched ) ) { - $content_raw = $fetched; - $has_real_content = true; - } - } - - $text = $title; - if ( $has_real_excerpt ) { - $text .= "\n\n" . $excerpt; - } - if ( $has_real_content ) { - $text .= "\n\n" . mb_substr( $content_raw, 0, 3000 ); - } - - return "Summarize this peptide research article in 3-4 sentences. " - . "Be concise, factual, and accessible to a general audience interested in peptide science. " - . "Do not include any preamble or labels — just the summary text.\n\n" - . "Article:\n" . $text; - } - - /** - * Fetch article text from a URL for summarization when RSS content is sparse. - * - * Extracts the main body text from the page, stripping navigation, - * scripts, styles, and other non-content elements. - * - * @param string $url The article URL. - * @return string Cleaned plain text or empty string on failure. - */ - private static function fetch_article_text( $url ) { - $response = wp_remote_get( $url, array( - 'timeout' => 10, - 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)', - 'headers' => array( 'Accept' => 'text/html' ), - ) ); - - if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { - return ''; - } - - $html = wp_remote_retrieve_body( $response ); - if ( empty( $html ) ) { - return ''; - } - - // Strip elements that don't carry article content. - $html = preg_replace( '/]*>.*?<\/script>/is', '', $html ); - $html = preg_replace( '/]*>.*?<\/style>/is', '', $html ); - $html = preg_replace( '/]*>.*?<\/nav>/is', '', $html ); - $html = preg_replace( '/]*>.*?<\/header>/is', '', $html ); - $html = preg_replace( '/]*>.*?<\/footer>/is', '', $html ); - $html = preg_replace( '/]*>.*?<\/aside>/is', '', $html ); - $html = preg_replace( '//s', '', $html ); - - // Try to extract content from
or
tags first. - $text = ''; - if ( preg_match( '/]*>(.*?)<\/article>/is', $html, $matches ) ) { - $text = wp_strip_all_tags( $matches[1] ); - } elseif ( preg_match( '/]*>(.*?)<\/main>/is', $html, $matches ) ) { - $text = wp_strip_all_tags( $matches[1] ); - } else { - $text = wp_strip_all_tags( $html ); - } - - // Collapse whitespace. - $text = preg_replace( '/\s+/', ' ', $text ); - $text = trim( $text ); - - // Only return if we got meaningful content. - if ( mb_strlen( $text ) < 100 ) { - return ''; - } - - return $text; - } - - /** - * Call the OpenRouter API with retry logic for 429 rate limits. - * - * @param string $api_key - * @param string $model - * @param string $prompt - * @return string|WP_Error The response text or an error. - */ - public static function call_openrouter( $api_key, $model, $prompt ) { - $body = array( - 'model' => $model, - 'messages' => array( - array( - 'role' => 'user', - 'content' => $prompt, - ), - ), - 'max_tokens' => 300, - 'temperature' => 0.3, - ); - - $retries = 0; - $backoff = 2; // Initial backoff in seconds. - - while ( $retries <= self::MAX_RETRIES ) { - $response = wp_remote_post( self::get_api_url(), array( - 'timeout' => 15, - 'headers' => array( - 'Authorization' => 'Bearer ' . $api_key, - 'Content-Type' => 'application/json', - 'HTTP-Referer' => home_url(), - 'X-Title' => 'Peptide News Aggregator', - ), - 'body' => wp_json_encode( $body ), - ) ); - - if ( is_wp_error( $response ) ) { - return $response; - } - - $status = wp_remote_retrieve_response_code( $response ); - - // Handle rate limiting with exponential backoff. - if ( 429 === $status && $retries < self::MAX_RETRIES ) { - $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); - $wait = $retry_after ? min( (int) $retry_after, 30 ) : $backoff; - sleep( $wait ); - $retries++; - $backoff *= 2; - continue; - } - - break; - } - - $data = json_decode( wp_remote_retrieve_body( $response ), true ); - - if ( $status < 200 || $status >= 300 ) { - $error_msg = 'HTTP ' . $status; - if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) { - $error_msg = $data['error']['message']; - } - return new WP_Error( 'openrouter_error', $error_msg ); - } - - // Validate the response structure thoroughly. - if ( ! is_array( $data ) ) { - return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); - } - if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) { - return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); - } - if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) { - return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); - } - if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) { - return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); - } - - return trim( $data['choices'][0]['message']['content'] ); - } - - /** - * Sanitize and normalize keyword output from the LLM. - * - * @param string $raw Raw comma-separated keywords. - * @return string Cleaned, deduplicated, comma-separated list. - */ - private static function sanitize_keywords( $raw ) { - // Strip all HTML first to prevent XSS. - $raw = wp_strip_all_tags( $raw ); - - // Remove any numbering, bullets, or extra formatting. - $raw = preg_replace( '/^\d+[\.\)]\s*/m', '', $raw ); - $raw = preg_replace( '/^[-*\x{2022}]\s*/mu', '', $raw ); - - $keywords = array_map( 'trim', explode( ',', $raw ) ); - $keywords = array_map( 'sanitize_text_field', $keywords ); - $keywords = array_filter( $keywords ); - $keywords = array_unique( $keywords ); - - // Limit to 10 keywords max. - $keywords = array_slice( $keywords, 0, 10 ); - - return implode( ', ', $keywords ); - } - - /** - * Save LLM results to the database. - * - * Note: Cache is NOT cleared here — the caller (process_unanalyzed) - * clears cache once per batch for efficiency. - * - * @param int $article_id - * @param array $results Array with 'keywords' and 'summary'. - * @param bool $force Overwrite existing values. - */ - private static function save_results( $article_id, $results, $force = false ) { - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - - $update = array(); - $format = array(); - - if ( ! empty( $results['keywords'] ) ) { - $update['tags'] = $results['keywords']; - $format[] = '%s'; - } - - if ( ! empty( $results['summary'] ) ) { - $update['ai_summary'] = $results['summary']; - $format[] = '%s'; - } - - if ( ! empty( $update ) ) { - $wpdb->update( - $table, - $update, - array( 'id' => $article_id ), - $format, - array( '%d' ) - ); - } - } - - /** - * AJAX handler: bulk-generate AI summaries for articles that are missing them. - * - * Processes up to 50 articles per request. Returns the count of articles - * processed and remaining so the admin UI can loop until done. - * - * @since 2.0.1 - */ - public static function ajax_generate_summaries() { - check_ajax_referer( 'peptide_news_admin', 'nonce' ); - if ( ! current_user_can( 'manage_options' ) ) { - wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); - } - - if ( ! self::is_enabled() ) { - 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', - 'qwen/qwen3.6-plus-preview:free', - ); - $working_model = 'google/gemma-3-27b-it:free'; - foreach ( array( 'peptide_news_llm_keywords_model', 'peptide_news_llm_summary_model' ) as $opt ) { - if ( in_array( get_option( $opt, '' ), $broken_models, true ) ) { - update_option( $opt, $working_model ); - } - } - - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - - // Count how many still need processing. - $remaining = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM {$table} - WHERE is_active = 1 - AND ( ai_summary = '' OR ai_summary IS NULL )" - ); - - if ( $remaining < 1 ) { - wp_send_json_success( array( - 'processed' => 0, - 'remaining' => 0, - 'message' => 'All articles already have AI summaries.', - ) ); - } - - // Process ONE article per request to stay within PHP's - // max_execution_time (often 30s on shared hosting). Each article - // requires 2 API calls (keywords + summary) which can take 10-20s - // with free models. The JS UI loops automatically until done. - $processed = self::process_unanalyzed( 1, true ); - - // Recount remaining after this batch. - $still_remaining = (int) $wpdb->get_var( - "SELECT COUNT(*) FROM {$table} - WHERE is_active = 1 - AND ( ai_summary = '' OR ai_summary IS NULL )" - ); - - // Include error details from the last processing run for debugging. - $last_run = get_option( 'peptide_news_last_llm_process', array() ); - $errors = isset( $last_run['errors'] ) ? $last_run['errors'] : array(); - - wp_send_json_success( array( - 'processed' => $processed, - 'remaining' => $still_remaining, - 'message' => sprintf( '%d article(s) summarized, %d remaining.', $processed, $still_remaining ), - 'errors' => $errors, - ) ); - } - - /** - * Clear all article transient caches. - */ - private static 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_' ) . '%' - ) - ); - } - - /** - * Log an error to the WordPress debug log. - * - * @param string $message - */ - private static function log_error( $message ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( '[Peptide News LLM] ' . $message ); - } - } + /** @var int Maximum retries for rate-limited requests. */ + const MAX_RETRIES = 1; + + /** @var int Maximum elapsed seconds before stopping a batch. */ + const BATCH_TIMEOUT = 120; + + /** + * Get the OpenRouter API URL from options with a hardcoded default. + * + * @return string + */ + private static function get_api_url() { + return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' ); + } + + /** + * Check whether LLM processing is enabled and configured. + * + * @return bool + */ + public static function is_enabled() { + $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 ); + } + + /** + * Validate an OpenRouter model ID format. + * + * Accepts patterns like: provider/model-name, provider/model-name:version + * + * @param string $model + * @return bool + */ + public static function is_valid_model( $model ) { + return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model ); + } + + /** + * Process a single article: extract keywords and generate a summary. + * + * Skips articles that already have both tags and ai_summary unless $force is true. + * + * @param object $article Database row with id, title, excerpt, content, categories. + * @param bool $force Re-process even if already analyzed. + * @return array Results with 'keywords', 'summary', and 'success' keys. + */ + public static function process_article( $article, $force = false ) { + if ( ! self::is_enabled() ) { + return array( 'keywords' => '', 'summary' => '', 'success' => false ); + } + + $results = array( + 'keywords' => '', + 'summary' => '', + 'success' => false, + 'errors' => array(), + ); + + $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + $any_success = false; + + // --- Keyword extraction --- + $keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' ); + $need_keywords = $force || empty( $article->tags ); + + if ( $need_keywords && self::is_valid_model( $keywords_model ) ) { + $keywords_prompt = self::build_keywords_prompt( $article ); + $keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt ); + + 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; + 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; + Peptide_News_Logger::error( 'Invalid keywords model ID: ' . $keywords_model, 'llm' ); + } + + // --- Summarization --- + $summary_model = get_option( 'peptide_news_llm_summary_model', 'google/gemini-2.0-flash-001' ); + $need_summary = $force || empty( $article->ai_summary ); + + if ( $need_summary && self::is_valid_model( $summary_model ) ) { + $summary_prompt = self::build_summary_prompt( $article ); + $summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt ); + + if ( ! is_wp_error( $summary_result ) ) { + // 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; + 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; + Peptide_News_Logger::error( 'Invalid summary model ID: ' . $summary_model, 'llm' ); + } + + $results['success'] = $any_success; + + // --- Persist results --- + if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) { + self::save_results( $article->id, $results, $force ); + } + + return $results; + } + + /** + * Process all unanalyzed articles (called after a fetch cycle). + * + * Includes a time-based guard to prevent cron timeouts and + * respects the admin-configured max articles per cycle unless + * $override_limit is true (used by the manual bulk-generate button). + * + * @param int $batch_size Max articles to process per run. + * @param bool $override_limit When true, ignore the admin "max per cycle" cap. + * @return int Number of articles successfully processed. + */ + public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) { + if ( ! self::is_enabled() ) { + return 0; + } + + if ( ! $override_limit ) { + // Respect admin cost-control setting for automated cron runs. + $max_per_cycle = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); + if ( $max_per_cycle < 1 ) { + $max_per_cycle = 10; + } + $batch_size = min( $batch_size, $max_per_cycle ); + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + // Find articles missing tags OR ai_summary. + $articles = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title, excerpt, content, categories, tags, ai_summary + FROM {$table} + WHERE is_active = 1 + AND ( tags = '' OR ai_summary = '' OR ai_summary IS NULL ) + ORDER BY fetched_at DESC + LIMIT %d", + $batch_size + ) ); + + $processed = 0; + $last_errors = array(); + $start_time = time(); + + foreach ( $articles as $article ) { + // Timeout guard: stop if approaching the limit. + if ( ( time() - $start_time ) >= self::BATCH_TIMEOUT ) { + self::log_error( 'Batch timeout reached after ' . $processed . ' articles. Remaining will process next cycle.' ); + break; + } + + $result = self::process_article( $article ); + + // Only count successful processing. + if ( ! empty( $result['success'] ) ) { + $processed++; + } + + // Track errors for debugging. + if ( ! empty( $result['errors'] ) ) { + $last_errors = $result['errors']; + } + + // Small delay to avoid rate limiting. + if ( next( $articles ) !== false ) { + usleep( 500000 ); // 0.5s between articles. + } + } + + // Clear article cache once per batch (not per article). + if ( $processed > 0 ) { + self::clear_article_cache(); + } + + // Log the processing result. + update_option( 'peptide_news_last_llm_process', array( + 'time' => current_time( 'mysql' ), + 'processed' => $processed, + 'attempted' => count( $articles ), + '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; + } + + /** + * Build the prompt for keyword extraction. + * + * @param object $article + * @return string + */ + private static function build_keywords_prompt( $article ) { + $text = $article->title; + if ( ! empty( $article->excerpt ) ) { + $text .= "\n\n" . $article->excerpt; + } + if ( ! empty( $article->content ) ) { + // Limit content to ~2000 chars to keep token usage reasonable. + $text .= "\n\n" . mb_substr( wp_strip_all_tags( $article->content ), 0, 2000 ); + } + + return "Extract 5-10 relevant keywords or key phrases from this peptide research article. " + . "Return ONLY a comma-separated list of keywords, nothing else. No numbering, no explanations.\n\n" + . "Article:\n" . $text; + } + + /** + * Build the prompt for article summarization. + * + * When the article's stored content is sparse (empty or just the title), + * attempts to fetch the actual page content from the source URL. + * + * @param object $article + * @return string + */ + private static function build_summary_prompt( $article ) { + $title = trim( $article->title ?? '' ); + $excerpt = trim( $article->excerpt ?? '' ); + $content = trim( $article->content ?? '' ); + $content_raw = wp_strip_all_tags( $content ); + + // Determine if we have meaningful content beyond just the title. + $has_real_excerpt = ! empty( $excerpt ) && $excerpt !== $title; + $has_real_content = mb_strlen( $content_raw ) > 100; + + // If content is sparse, try fetching the article's web page. + if ( ! $has_real_content && ! empty( $article->source_url ) ) { + $fetched = self::fetch_article_text( $article->source_url ); + if ( ! empty( $fetched ) ) { + $content_raw = $fetched; + $has_real_content = true; + } + } + + $text = $title; + if ( $has_real_excerpt ) { + $text .= "\n\n" . $excerpt; + } + if ( $has_real_content ) { + $text .= "\n\n" . mb_substr( $content_raw, 0, 3000 ); + } + + return "Summarize this peptide research article in 3-4 sentences. " + . "Be concise, factual, and accessible to a general audience interested in peptide science. " + . "Do not include any preamble or labels — just the summary text.\n\n" + . "Article:\n" . $text; + } + + /** + * Fetch article text from a URL for summarization when RSS content is sparse. + * + * Extracts the main body text from the page, stripping navigation, + * scripts, styles, and other non-content elements. + * + * @param string $url The article URL. + * @return string Cleaned plain text or empty string on failure. + */ + private static function fetch_article_text( $url ) { + $response = wp_remote_get( $url, array( + 'timeout' => 10, + 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)', + 'headers' => array( 'Accept' => 'text/html' ), + ) ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return ''; + } + + $html = wp_remote_retrieve_body( $response ); + if ( empty( $html ) ) { + return ''; + } + + // Strip elements that don't carry article content. + $html = preg_replace( '/]*>.*?<\/script>/is', '', $html ); + $html = preg_replace( '/]*>.*?<\/style>/is', '', $html ); + $html = preg_replace( '/]*>.*?<\/nav>/is', '', $html ); + $html = preg_replace( '/]*>.*?<\/header>/is', '', $html ); + $html = preg_replace( '/]*>.*?<\/footer>/is', '', $html ); + $html = preg_replace( '/]*>.*?<\/aside>/is', '', $html ); + $html = preg_replace( '//s', '', $html ); + + // Try to extract content from
or
tags first. + $text = ''; + if ( preg_match( '/]*>(.*?)<\/article>/is', $html, $matches ) ) { + $text = wp_strip_all_tags( $matches[1] ); + } elseif ( preg_match( '/]*>(.*?)<\/main>/is', $html, $matches ) ) { + $text = wp_strip_all_tags( $matches[1] ); + } else { + $text = wp_strip_all_tags( $html ); + } + + // Collapse whitespace. + $text = preg_replace( '/\s+/', ' ', $text ); + $text = trim( $text ); + + // Only return if we got meaningful content. + if ( mb_strlen( $text ) < 100 ) { + return ''; + } + + return $text; + } + + /** + * Call the OpenRouter API with retry logic for 429 rate limits. + * + * @param string $api_key + * @param string $model + * @param string $prompt + * @return string|WP_Error The response text or an error. + */ + public static function call_openrouter( $api_key, $model, $prompt ) { + $body = array( + 'model' => $model, + 'messages' => array( + array( + 'role' => 'user', + 'content' => $prompt, + ), + ), + 'max_tokens' => 300, + 'temperature' => 0.3, + ); + + $retries = 0; + $backoff = 2; // Initial backoff in seconds. + + while ( $retries <= self::MAX_RETRIES ) { + $response = wp_remote_post( self::get_api_url(), array( + 'timeout' => 15, + 'headers' => array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'Peptide News Aggregator', + ), + 'body' => wp_json_encode( $body ), + ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status = wp_remote_retrieve_response_code( $response ); + + // Handle rate limiting with exponential backoff. + if ( 429 === $status && $retries < self::MAX_RETRIES ) { + $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); + $wait = $retry_after ? min( (int) $retry_after, 30 ) : $backoff; + sleep( $wait ); + $retries++; + $backoff *= 2; + continue; + } + + break; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status < 200 || $status >= 300 ) { + $error_msg = 'HTTP ' . $status; + if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) { + $error_msg = $data['error']['message']; + } + return new WP_Error( 'openrouter_error', $error_msg ); + } + + // Validate the response structure thoroughly. + if ( ! is_array( $data ) ) { + return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' ); + } + if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) { + return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' ); + } + if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) { + return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' ); + } + if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) { + return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' ); + } + + return trim( $data['choices'][0]['message']['content'] ); + } + + /** + * Sanitize and normalize keyword output from the LLM. + * + * @param string $raw Raw comma-separated keywords. + * @return string Cleaned, deduplicated, comma-separated list. + */ + private static function sanitize_keywords( $raw ) { + // Strip all HTML first to prevent XSS. + $raw = wp_strip_all_tags( $raw ); + + // Remove any numbering, bullets, or extra formatting. + $raw = preg_replace( '/^\d+[\.\)]\s*/m', '', $raw ); + $raw = preg_replace( '/^[-*\x{2022}]\s*/mu', '', $raw ); + + $keywords = array_map( 'trim', explode( ',', $raw ) ); + $keywords = array_map( 'sanitize_text_field', $keywords ); + $keywords = array_filter( $keywords ); + $keywords = array_unique( $keywords ); + + // Limit to 10 keywords max. + $keywords = array_slice( $keywords, 0, 10 ); + + return implode( ', ', $keywords ); + } + + /** + * Save LLM results to the database. + * + * Note: Cache is NOT cleared here — the caller (process_unanalyzed) + * clears cache once per batch for efficiency. + * + * @param int $article_id + * @param array $results Array with 'keywords' and 'summary'. + * @param bool $force Overwrite existing values. + */ + private static function save_results( $article_id, $results, $force = false ) { + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + $update = array(); + $format = array(); + + if ( ! empty( $results['keywords'] ) ) { + $update['tags'] = $results['keywords']; + $format[] = '%s'; + } + + if ( ! empty( $results['summary'] ) ) { + $update['ai_summary'] = $results['summary']; + $format[] = '%s'; + } + + if ( ! empty( $update ) ) { + $wpdb->update( + $table, + $update, + array( 'id' => $article_id ), + $format, + array( '%d' ) + ); + } + } + + /** + * AJAX handler: bulk-generate AI summaries for articles that are missing them. + * + * Processes up to 50 articles per request. Returns the count of articles + * processed and remaining so the admin UI can loop until done. + * + * @since 2.0.1 + */ + public static function ajax_generate_summaries() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 ); + } + + if ( ! self::is_enabled() ) { + 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', + 'qwen/qwen3.6-plus-preview:free', + ); + $working_model = 'google/gemma-3-27b-it:free'; + foreach ( array( 'peptide_news_llm_keywords_model', 'peptide_news_llm_summary_model' ) as $opt ) { + if ( in_array( get_option( $opt, '' ), $broken_models, true ) ) { + update_option( $opt, $working_model ); + } + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + // Count how many still need processing. + $remaining = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} + WHERE is_active = 1 + AND ( ai_summary = '' OR ai_summary IS NULL )" + ); + + if ( $remaining < 1 ) { + wp_send_json_success( array( + 'processed' => 0, + 'remaining' => 0, + 'message' => 'All articles already have AI summaries.', + ) ); + } + + // Process ONE article per request to stay within PHP's + // max_execution_time (often 30s on shared hosting). Each article + // requires 2 API calls (keywords + summary) which can take 10-20s + // with free models. The JS UI loops automatically until done. + $processed = self::process_unanalyzed( 1, true ); + + // Recount remaining after this batch. + $still_remaining = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} + WHERE is_active = 1 + AND ( ai_summary = '' OR ai_summary IS NULL )" + ); + + // Include error details from the last processing run for debugging. + $last_run = get_option( 'peptide_news_last_llm_process', array() ); + $errors = isset( $last_run['errors'] ) ? $last_run['errors'] : array(); + + wp_send_json_success( array( + 'processed' => $processed, + 'remaining' => $still_remaining, + 'message' => sprintf( '%d article(s) summarized, %d remaining.', $processed, $still_remaining ), + 'errors' => $errors, + ) ); + } + + /** + * Clear all article transient caches. + */ + private static 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_' ) . '%' + ) + ); + } + + /** + * Log an error to the WordPress debug log. + * + * @param string $message + */ + private static function log_error( $message ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( '[Peptide News LLM] ' . $message ); + } + } } diff --git a/includes/class-peptide-news-loader.php b/includes/class-peptide-news-loader.php index fd84937..40ea35c 100644 --- a/includes/class-peptide-news-loader.php +++ b/includes/class-peptide-news-loader.php @@ -12,109 +12,109 @@ class Peptide_News_Loader { - /** - * The array of actions registered with WordPress. - * - * @since 1.0.0 - * @access protected - * @var array $actions The actions registered with WordPress to fire when the plugin loads. - */ - protected $actions; + /** + * The array of actions registered with WordPress. + * + * @since 1.0.0 + * @access protected + * @var array $actions The actions registered with WordPress to fire when the plugin loads. + */ + protected $actions; - /** - * The array of filters registered with WordPress. - * - * @since 1.0.0 - * @access protected - * @var array $filters The filters registered with WordPress. - */ - protected $filters; + /** + * The array of filters registered with WordPress. + * + * @since 1.0.0 + * @access protected + * @var array $filters The filters registered with WordPress. + */ + protected $filters; - /** - * Initialize the collections used to maintain the actions and filters. - * - * @since 1.0.0 - */ - public function __construct() { - $this->actions = array(); - $this->filters = array(); - } + /** + * Initialize the collections used to maintain the actions and filters. + * + * @since 1.0.0 + */ + public function __construct() { + $this->actions = array(); + $this->filters = array(); + } - /** - * Add a new action to the collection to be registered with WordPress. - * - * @since 1.0.0 - * @param string $hook The name of the WordPress action that is being registered. - * @param object $component A reference to the instance of the object on which the action is defined. - * @param string $callback The name of the function definition on the $component. - * @param int $priority Optional. The priority at which the function should be fired. Default is 10. - * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. - */ - public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { - $this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args ); - } + /** + * Add a new action to the collection to be registered with WordPress. + * + * @since 1.0.0 + * @param string $hook The name of the WordPress action that is being registered. + * @param object $component A reference to the instance of the object on which the action is defined. + * @param string $callback The name of the function definition on the $component. + * @param int $priority Optional. The priority at which the function should be fired. Default is 10. + * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. + */ + public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { + $this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args ); + } - /** - * Add a new filter to the collection to be registered with WordPress. - * - * @since 1.0.0 - * @param string $hook The name of the WordPress filter that is being registered. - * @param object $component A reference to the instance of the object on which the filter is defined. - * @param string $callback The name of the function definition on the $component. - * @param int $priority Optional. The priority at which the function should be fired. Default is 10. - * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. - */ - public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { - $this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args ); - } + /** + * Add a new filter to the collection to be registered with WordPress. + * + * @since 1.0.0 + * @param string $hook The name of the WordPress filter that is being registered. + * @param object $component A reference to the instance of the object on which the filter is defined. + * @param string $callback The name of the function definition on the $component. + * @param int $priority Optional. The priority at which the function should be fired. Default is 10. + * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. + */ + public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { + $this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args ); + } - /** - * A utility function that is used to register the actions and hooks into a single collection. - * - * @since 1.0.0 - * @access private - * @param array $hooks The collection of hooks that is being registered (actions or filters). - * @param string $hook The name of the WordPress filter that is being registered. - * @param object $component A reference to the instance of the object on which the filter is defined. - * @param string $callback The name of the function definition on the $component. - * @param int $priority The priority at which the function should be fired. - * @param int $accepted_args The number of arguments that should be passed to the $callback. - * @return array The collection of actions and filters registered with WordPress. - */ - private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) { - $hooks[] = array( - 'hook' => $hook, - 'component' => $component, - 'callback' => $callback, - 'priority' => $priority, - 'accepted_args' => $accepted_args, - ); + /** + * A utility function that is used to register the actions and hooks into a single collection. + * + * @since 1.0.0 + * @access private + * @param array $hooks The collection of hooks that is being registered (actions or filters). + * @param string $hook The name of the WordPress filter that is being registered. + * @param object $component A reference to the instance of the object on which the filter is defined. + * @param string $callback The name of the function definition on the $component. + * @param int $priority The priority at which the function should be fired. + * @param int $accepted_args The number of arguments that should be passed to the $callback. + * @return array The collection of actions and filters registered with WordPress. + */ + private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) { + $hooks[] = array( + 'hook' => $hook, + 'component' => $component, + 'callback' => $callback, + 'priority' => $priority, + 'accepted_args' => $accepted_args, + ); - return $hooks; - } + return $hooks; + } - /** - * Register the filters and actions with WordPress. - * - * @since 1.0.0 - */ - public function run() { - foreach ( $this->filters as $hook ) { - add_filter( - $hook['hook'], - array( $hook['component'], $hook['callback'] ), - $hook['priority'], - $hook['accepted_args'] - ); - } + /** + * Register the filters and actions with WordPress. + * + * @since 1.0.0 + */ + public function run() { + foreach ( $this->filters as $hook ) { + add_filter( + $hook['hook'], + array( $hook['component'], $hook['callback'] ), + $hook['priority'], + $hook['accepted_args'] + ); + } - foreach ( $this->actions as $hook ) { - add_action( - $hook['hook'], - array( $hook['component'], $hook['callback'] ), - $hook['priority'], - $hook['accepted_args'] - ); - } - } + foreach ( $this->actions as $hook ) { + add_action( + $hook['hook'], + array( $hook['component'], $hook['callback'] ), + $hook['priority'], + $hook['accepted_args'] + ); + } + } } diff --git a/includes/class-peptide-news-logger.php b/includes/class-peptide-news-logger.php index 83dcd1d..20cfbdf 100644 --- a/includes/class-peptide-news-logger.php +++ b/includes/class-peptide-news-logger.php @@ -9,246 +9,246 @@ */ class Peptide_News_Logger { - /** Log level constants. */ - const LEVEL_INFO = 'info'; - const LEVEL_WARNING = 'warning'; - const LEVEL_ERROR = 'error'; - const LEVEL_DEBUG = 'debug'; + /** Log level constants. */ + const LEVEL_INFO = 'info'; + const LEVEL_WARNING = 'warning'; + const LEVEL_ERROR = 'error'; + const LEVEL_DEBUG = 'debug'; - /** Maximum rows kept in the log table (auto-pruned on write). */ - const MAX_ROWS = 2000; + /** Maximum rows kept in the log table (auto-pruned on write). */ + const MAX_ROWS = 2000; - /** - * Return the log table name. - */ - public static function table() { - global $wpdb; - return $wpdb->prefix . 'peptide_news_log'; - } + /** + * Return the log table name. + */ + public static function table() { + global $wpdb; + return $wpdb->prefix . 'peptide_news_log'; + } - /* ------------------------------------------------------------------ - * Writing - * ----------------------------------------------------------------*/ + /* ------------------------------------------------------------------ + * 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; + /** + * 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' ) - ); + $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(); - } + // 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: 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: 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: 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 ); - } - } + /** 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 - * ----------------------------------------------------------------*/ + /* ------------------------------------------------------------------ + * 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; + /** + * 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 ); + $defaults = array( + 'level' => '', + 'context' => '', + 'limit' => 50, + 'offset' => 0, + ); + $args = wp_parse_args( $args, $defaults ); - $table = self::table(); - $where = array( '1=1' ); - $vals = array(); + $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']; - } + 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 ); + $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 ) ); + // 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 ) ); + // 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, - ); - } + return array( + 'rows' => $rows ? $rows : array(), + 'total' => $total, + ); + } - /* ------------------------------------------------------------------ - * Maintenance - * ----------------------------------------------------------------*/ + /* ------------------------------------------------------------------ + * Maintenance + * ----------------------------------------------------------------*/ - /** - * Remove rows beyond the MAX_ROWS cap. - */ - private static function maybe_prune() { - global $wpdb; - $table = self::table(); + /** + * 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; - } + // Only prune every ~50 writes (probabilistic). + if ( wp_rand( 1, 50 ) !== 1 ) { + return; + } - $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); + $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 - ) ); - } - } + 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() ); - } + /** + * Clear all log entries. + */ + public static function clear() { + global $wpdb; + $wpdb->query( "TRUNCATE TABLE " . self::table() ); + } - /* ------------------------------------------------------------------ - * AJAX handlers - * ----------------------------------------------------------------*/ + /* ------------------------------------------------------------------ + * AJAX handlers + * ----------------------------------------------------------------*/ - /** - * AJAX: fetch paginated log entries. - */ - public static function ajax_get_logs() { - check_ajax_referer( 'peptide_news_admin', 'nonce' ); + /** + * 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 ); - } + 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; + $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, - ) ); + $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 ), - ) ); - } + 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' ); + /** + * 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 ); - } + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } - self::clear(); - self::info( 'Log cleared by admin.', 'admin' ); + self::clear(); + self::info( 'Log cleared by admin.', 'admin' ); - wp_send_json_success( array( 'message' => 'Log cleared.' ) ); - } + wp_send_json_success( array( 'message' => 'Log cleared.' ) ); + } - /* ------------------------------------------------------------------ - * Schema - * ----------------------------------------------------------------*/ + /* ------------------------------------------------------------------ + * Schema + * ----------------------------------------------------------------*/ - /** - * Create the log table (called from activator / upgrade check). - */ - public static function create_table() { - global $wpdb; + /** + * 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(); + $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};"; + $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 ); - } + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + } } diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php index c63a6ee..8dc07b9 100644 --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -17,418 +17,418 @@ */ class Peptide_News_Rest_API { - const API_NAMESPACE = 'peptide-news/v1'; + const API_NAMESPACE = 'peptide-news/v1'; - /** - * Register REST routes. - */ - public function register_routes() { + /** + * Register REST routes. + */ + public function register_routes() { - // Public: list articles. - register_rest_route( self::API_NAMESPACE, '/articles', array( - 'methods' => 'GET', - 'callback' => array( $this, 'get_articles' ), - 'permission_callback' => '__return_true', - 'args' => array( - 'count' => array( - 'default' => 10, - 'sanitize_callback' => 'absint', - ), - 'page' => array( - 'default' => 1, - 'sanitize_callback' => 'absint', - ), - ), - ) ); + // Public: list articles. + register_rest_route( self::API_NAMESPACE, '/articles', array( + 'methods' => 'GET', + 'callback' => array( $this, 'get_articles' ), + 'permission_callback' => '__return_true', + 'args' => array( + 'count' => array( + 'default' => 10, + 'sanitize_callback' => 'absint', + ), + 'page' => array( + 'default' => 1, + 'sanitize_callback' => 'absint', + ), + ), + ) ); - // Admin-only analytics endpoints. - $analytics_routes = array( - '/analytics/top' => 'get_top_articles', - '/analytics/trends' => 'get_trends', - '/analytics/topics' => 'get_topics', - '/analytics/devices' => 'get_devices', - '/analytics/sources' => 'get_sources', - '/analytics/export' => 'get_export', - ); + // Admin-only analytics endpoints. + $analytics_routes = array( + '/analytics/top' => 'get_top_articles', + '/analytics/trends' => 'get_trends', + '/analytics/topics' => 'get_topics', + '/analytics/devices' => 'get_devices', + '/analytics/sources' => 'get_sources', + '/analytics/export' => 'get_export', + ); - foreach ( $analytics_routes as $route => $callback ) { - register_rest_route( self::API_NAMESPACE, $route, array( - 'methods' => 'GET', - 'callback' => array( $this, $callback ), - 'permission_callback' => array( $this, 'check_admin_permissions' ), - 'args' => $this->get_date_range_args(), - ) ); - } - } + foreach ( $analytics_routes as $route => $callback ) { + register_rest_route( self::API_NAMESPACE, $route, array( + 'methods' => 'GET', + 'callback' => array( $this, $callback ), + 'permission_callback' => array( $this, 'check_admin_permissions' ), + 'args' => $this->get_date_range_args(), + ) ); + } + } - /** - * Verify the request comes from an admin user. - * - * @param WP_REST_Request $request - * @return bool|WP_Error - */ - public function check_admin_permissions( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { - return new WP_Error( - 'rest_forbidden', - __( 'You do not have permission to access analytics data.', 'peptide-news' ), - array( 'status' => 403 ) - ); - } - return true; - } + /** + * Verify the request comes from an admin user. + * + * @param WP_REST_Request $request + * @return bool|WP_Error + */ + public function check_admin_permissions( $request ) { + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'rest_forbidden', + __( 'You do not have permission to access analytics data.', 'peptide-news' ), + array( 'status' => 403 ) + ); + } + return true; + } - /** - * Common date-range argument definitions. - * - * @return array - */ - private function get_date_range_args() { - 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' ), - ), - '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', - ), - ); - } + /** + * Common date-range argument definitions. + * + * @return array + */ + private function get_date_range_args() { + 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' ), + ), + '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', + ), + ); + } - /** - * 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 - */ - 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', - sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ), - array( 'status' => 400 ) - ); - } - return true; - } + /** + * 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 + */ + 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 ) + ); + } + 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; + /** + * 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'; + $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 ); - } + // 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 ) ); + $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; + // 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 - ) ); + $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 ); + // 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; - } - } - } + // 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 ), + ); - $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 ); - // Cache the response for 1 hour. - set_transient( $cache_key, $data, HOUR_IN_SECONDS ); + $response = rest_ensure_response( $data ); - $response = rest_ensure_response( $data ); + return $response; + } - 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( $text, $source, $source_url = '' ) { + if ( empty( $text ) ) { + return $text; + } - /** - * 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( $text, $source, $source_url = '' ) { - if ( empty( $text ) ) { - return $text; - } + // Decode HTML entities first so   becomes actual characters. + $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); - // 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 + ); - // 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; + } - // 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 ); - // 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; + } + } + } - // 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 ) ); + } + } + } - // 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 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] ); + } + } - // 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; + } - return $text; - } + /** + * 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 ); + } - /** - * 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 ); - } + /** + * 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 ); + } - /** - * 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 ); - } + /** + * 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 ); + } - /** - * 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 ); - } + /** + * 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 ); + } - /** - * 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 ); - } + /** + * 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 ); + } - /** - * 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 ); - } + /** + * GET /analytics/export — returns CSV download. + */ + public function get_export( $request ) { + $data = Peptide_News_Analytics::export_clicks_csv( + $request->get_param( 'start_date' ), + $request->get_param( 'end_date' ) + ); - /** - * GET /analytics/export — returns CSV download. - */ - public function get_export( $request ) { - $data = Peptide_News_Analytics::export_clicks_csv( - $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.' ) ); + } - 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, + 'count' => count( $data ), + ) ); + } - // Return as JSON (client can convert to CSV). - return rest_ensure_response( array( - 'headers' => array_keys( $data[0] ), - 'rows' => $data, - 'count' => count( $data ), - ) ); - } + /** + * 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 + */ + public static function ajax_delete_articles() { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); - /** - * 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 - */ - public static function ajax_delete_articles() { - check_ajax_referer( 'peptide_news_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized.', 403 ); + } - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( 'Unauthorized.', 403 ); - } + $raw_ids = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : ''; + if ( empty( $raw_ids ) ) { + wp_send_json_error( 'No article IDs provided.' ); + } - $raw_ids = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : ''; - if ( empty( $raw_ids ) ) { - wp_send_json_error( 'No article IDs provided.' ); - } + $ids = array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) ); + if ( empty( $ids ) ) { + wp_send_json_error( 'Invalid article IDs.' ); + } - $ids = array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) ); - if ( empty( $ids ) ) { - wp_send_json_error( 'Invalid article IDs.' ); - } + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); - global $wpdb; - $table = $wpdb->prefix . 'peptide_news_articles'; - $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $deleted = $wpdb->query( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "DELETE FROM {$table} WHERE id IN ({$placeholders})", + $ids + ) + ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching - $deleted = $wpdb->query( - $wpdb->prepare( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - "DELETE FROM {$table} WHERE id IN ({$placeholders})", - $ids - ) - ); + if ( false === $deleted ) { + wp_send_json_error( 'Database error.' ); + } - if ( false === $deleted ) { - wp_send_json_error( 'Database error.' ); - } + // Clear article cache so the frontend reflects changes. + delete_transient( 'peptide_news_articles_1_10' ); - // 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' ); - 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.php b/includes/class-peptide-news.php index 5c74fdd..e6120cf 100644 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -8,111 +8,111 @@ */ class Peptide_News { - /** @var Peptide_News_Loader */ - protected $loader; + /** @var Peptide_News_Loader */ + protected $loader; - /** @var string */ - protected $plugin_name; + /** @var string */ + protected $plugin_name; - /** @var string */ - protected $version; + /** @var string */ + protected $version; - public function __construct() { - $this->version = PEPTIDE_NEWS_VERSION; - $this->plugin_name = 'peptide-news'; + public function __construct() { + $this->version = PEPTIDE_NEWS_VERSION; + $this->plugin_name = 'peptide-news'; - $this->load_dependencies(); - $this->define_admin_hooks(); - $this->define_public_hooks(); - $this->define_cron_hooks(); - $this->define_rest_hooks(); - } + $this->load_dependencies(); + $this->define_admin_hooks(); + $this->define_public_hooks(); + $this->define_cron_hooks(); + $this->define_rest_hooks(); + } - /** - * Load all required files. - */ - private function load_dependencies() { - require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php'; - require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; - require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; - require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; - 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'; + /** + * Load all required files. + */ + private function load_dependencies() { + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; + 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'; - $this->loader = new Peptide_News_Loader(); - } + $this->loader = new Peptide_News_Loader(); + } - /** - * Register admin-side hooks. - */ - private function define_admin_hooks() { - $admin = new Peptide_News_Admin( $this->plugin_name, $this->version ); + /** + * Register admin-side hooks. + */ + private function define_admin_hooks() { + $admin = new Peptide_News_Admin( $this->plugin_name, $this->version ); - $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); - $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' ); - $this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' ); - $this->loader->add_action( 'admin_init', $admin, 'register_settings' ); - } + $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); + $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' ); + $this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' ); + $this->loader->add_action( 'admin_init', $admin, 'register_settings' ); + } - /** - * Register public-facing hooks. - */ - private function define_public_hooks() { - $public = new Peptide_News_Public( $this->plugin_name, $this->version ); + /** + * Register public-facing hooks. + */ + private function define_public_hooks() { + $public = new Peptide_News_Public( $this->plugin_name, $this->version ); - $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); - $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' ); - $this->loader->add_action( 'init', $public, 'register_shortcode' ); - $this->loader->add_action( 'widgets_init', $public, 'register_widget' ); - $this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' ); - $this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' ); - } + $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); + $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' ); + $this->loader->add_action( 'init', $public, 'register_shortcode' ); + $this->loader->add_action( 'widgets_init', $public, 'register_widget' ); + $this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' ); + $this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' ); + } - /** - * Register WP-Cron hooks for scheduled fetching. - */ - private function define_cron_hooks() { - $fetcher = new Peptide_News_Fetcher(); + /** + * Register WP-Cron hooks for scheduled fetching. + */ + private function define_cron_hooks() { + $fetcher = new Peptide_News_Fetcher(); - $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); - $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' ); - $this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' ); - } + $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); + $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' ); + $this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' ); + } - /** - * Register REST API hooks. - */ - private function define_rest_hooks() { - $rest = new Peptide_News_Rest_API(); + /** + * Register REST API hooks. + */ + private function define_rest_hooks() { + $rest = new Peptide_News_Rest_API(); - $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); - } + $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); + } - /** - * Run the loader to execute all registered hooks. - */ - public function run() { - $this->loader->run(); - } + /** + * Run the loader to execute all registered hooks. + */ + public function run() { + $this->loader->run(); + } - public function get_plugin_name() { - return $this->plugin_name; - } + public function get_plugin_name() { + return $this->plugin_name; + } - public function get_version() { - return $this->version; - } + public function get_version() { + return $this->version; + } - public function get_loader() { - return $this->loader; - } + public function get_loader() { + return $this->loader; + } } diff --git a/peptide-news-plugin.php b/peptide-news-plugin.php index 100ee26..5d1e878 100644 --- a/peptide-news-plugin.php +++ b/peptide-news-plugin.php @@ -16,7 +16,7 @@ // Abort if called directly. if ( ! defined( 'WPINC' ) ) { - die; + die; } /** @@ -31,16 +31,16 @@ define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); * Runs during plugin activation. */ function peptide_news_activate() { - require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; - Peptide_News_Activator::activate(); + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; + Peptide_News_Activator::activate(); } /** * Runs during plugin deactivation. */ function peptide_news_deactivate() { - require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; - Peptide_News_Deactivator::deactivate(); + require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; + Peptide_News_Deactivator::deactivate(); } register_activation_hook( __FILE__, 'peptide_news_activate' ); @@ -57,12 +57,12 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news.php'; * without re-activating the plugin. */ 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(); - } + $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(); + } } add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' ); @@ -70,8 +70,8 @@ add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' ); * Begins execution of the plugin. */ function peptide_news_run() { - $plugin = new Peptide_News(); - $plugin->run(); + $plugin = new Peptide_News(); + $plugin->run(); } peptide_news_run(); diff --git a/public/class-peptide-news-public.php b/public/class-peptide-news-public.php index 056c0a1..7e961a4 100644 --- a/public/class-peptide-news-public.php +++ b/public/class-peptide-news-public.php @@ -10,161 +10,161 @@ */ class Peptide_News_Public { - /** @var string */ - private $plugin_name; + /** @var string */ + private $plugin_name; - /** @var string */ - private $version; + /** @var string */ + private $version; - public function __construct( $plugin_name, $version ) { - $this->plugin_name = $plugin_name; - $this->version = $version; - } + public function __construct( $plugin_name, $version ) { + $this->plugin_name = $plugin_name; + $this->version = $version; + } - /** - * Register front-end CSS (enqueued on demand by the shortcode/widget). - */ - public function enqueue_styles() { - wp_register_style( - $this->plugin_name, - PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', - array(), - $this->version, - 'all' - ); - } + /** + * Register front-end CSS (enqueued on demand by the shortcode/widget). + */ + public function enqueue_styles() { + wp_register_style( + $this->plugin_name, + PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', + array(), + $this->version, + 'all' + ); + } - /** - * Register front-end JS (enqueued on demand by the shortcode/widget). - * - * Loads React (from WordPress core), the feed component, and - * passes configuration via wp_localize_script. - */ - public function enqueue_scripts() { - // React feed component (depends on WP-bundled React). - wp_register_script( - $this->plugin_name . '-feed', - PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js', - array( 'react', 'react-dom' ), - $this->version, - true - ); - } + /** + * Register front-end JS (enqueued on demand by the shortcode/widget). + * + * Loads React (from WordPress core), the feed component, and + * passes configuration via wp_localize_script. + */ + public function enqueue_scripts() { + // React feed component (depends on WP-bundled React). + wp_register_script( + $this->plugin_name . '-feed', + PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js', + array( 'react', 'react-dom' ), + $this->version, + true + ); + } - /** - * Register the shortcode. - */ - public function register_shortcode() { - add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); - } + /** + * Register the shortcode. + */ + public function register_shortcode() { + add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); + } - /** - * Register the widget. - */ - public function register_widget() { - register_widget( 'Peptide_News_Widget' ); - } + /** + * Register the widget. + */ + public function register_widget() { + register_widget( 'Peptide_News_Widget' ); + } - /** - * Render the [peptide_news] shortcode. - * - * Outputs a mount-point div for the React feed component and - * enqueues all required assets. Configuration is passed to JS - * via a localized script object. - * - * @param array $atts Shortcode attributes. - * @return string HTML output. - */ - public function render_shortcode( $atts ) { - $atts = shortcode_atts( array( - 'count' => get_option( 'peptide_news_articles_count', 10 ), - ), $atts, 'peptide_news' ); + /** + * Render the [peptide_news] shortcode. + * + * Outputs a mount-point div for the React feed component and + * enqueues all required assets. Configuration is passed to JS + * via a localized script object. + * + * @param array $atts Shortcode attributes. + * @return string HTML output. + */ + public function render_shortcode( $atts ) { + $atts = shortcode_atts( array( + 'count' => get_option( 'peptide_news_articles_count', 10 ), + ), $atts, 'peptide_news' ); - $count = absint( $atts['count'] ); + $count = absint( $atts['count'] ); - // Enqueue assets only when the shortcode is actually rendered. - wp_enqueue_style( $this->plugin_name ); - wp_enqueue_script( $this->plugin_name . '-feed' ); + // Enqueue assets only when the shortcode is actually rendered. + wp_enqueue_style( $this->plugin_name ); + wp_enqueue_script( $this->plugin_name . '-feed' ); - // Pass configuration to the React component. - wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array( - 'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ), - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'peptide_news_track' ), - 'sessionId' => $this->get_or_create_session_id(), - 'count' => $count, - ) ); + // Pass configuration to the React component. + wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array( + 'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'peptide_news_track' ), + 'sessionId' => $this->get_or_create_session_id(), + 'count' => $count, + ) ); - // React mount point — the component renders into this div. - return '
'; - } + // React mount point — the component renders into this div. + return '
'; + } - /** - * Handle AJAX click tracking with basic rate limiting. - */ - public function handle_click_tracking() { - check_ajax_referer( 'peptide_news_track', 'nonce' ); + /** + * Handle AJAX click tracking with basic rate limiting. + */ + public function handle_click_tracking() { + check_ajax_referer( 'peptide_news_track', 'nonce' ); - $article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0; + $article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0; - if ( ! $article_id ) { - wp_send_json_error( 'Invalid article ID.' ); - } + if ( ! $article_id ) { + wp_send_json_error( 'Invalid article ID.' ); + } - // Rate limit: max 1 click per article per session per 5 seconds. - $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : ''; - $throttle_key = 'pn_click_' . md5( $session_id . '_' . $article_id ); + // Rate limit: max 1 click per article per session per 5 seconds. + $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : ''; + $throttle_key = 'pn_click_' . md5( $session_id . '_' . $article_id ); - if ( get_transient( $throttle_key ) ) { - wp_send_json_success( array( 'throttled' => true ) ); - } - set_transient( $throttle_key, 1, 5 ); + if ( get_transient( $throttle_key ) ) { + wp_send_json_success( array( 'throttled' => true ) ); + } + set_transient( $throttle_key, 1, 5 ); - $meta = array( - 'ip' => '', - 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', - 'referrer' => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( $_POST['referrer'] ) ) : '', - 'page_url' => isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '', - 'session_id' => $session_id, - ); + $meta = array( + 'ip' => '', + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', + 'referrer' => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( $_POST['referrer'] ) ) : '', + 'page_url' => isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '', + 'session_id' => $session_id, + ); - $result = Peptide_News_Analytics::record_click( $article_id, $meta ); + $result = Peptide_News_Analytics::record_click( $article_id, $meta ); - if ( $result ) { - wp_send_json_success(); - } else { - wp_send_json_error( 'Failed to record click.' ); - } - } + if ( $result ) { + wp_send_json_success(); + } else { + wp_send_json_error( 'Failed to record click.' ); + } + } - /** - * Generate or retrieve a session ID for visitor tracking. - * - * @return string - */ - private function get_or_create_session_id() { - if ( isset( $_COOKIE['pn_session_id'] ) ) { - return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) ); - } + /** + * Generate or retrieve a session ID for visitor tracking. + * + * @return string + */ + private function get_or_create_session_id() { + if ( isset( $_COOKIE['pn_session_id'] ) ) { + return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) ); + } - $session_id = wp_generate_uuid4(); + $session_id = wp_generate_uuid4(); - if ( ! headers_sent() ) { - setcookie( - 'pn_session_id', - $session_id, - array( - 'expires' => time() + 1800, - 'path' => '/', - 'secure' => is_ssl(), - 'httponly' => true, - 'samesite' => 'Lax', - ) - ); - } + if ( ! headers_sent() ) { + setcookie( + 'pn_session_id', + $session_id, + array( + 'expires' => time() + 1800, + 'path' => '/', + 'secure' => is_ssl(), + 'httponly' => true, + 'samesite' => 'Lax', + ) + ); + } - return $session_id; - } + return $session_id; + } } /** @@ -174,63 +174,63 @@ class Peptide_News_Public { */ class Peptide_News_Widget extends WP_Widget { - public function __construct() { - parent::__construct( - 'peptide_news_widget', - __( 'Peptide News', 'peptide-news' ), - array( - 'description' => __( 'Displays the latest peptide research news.', 'peptide-news' ), - 'classname' => 'peptide-news-widget', - ) - ); - } + public function __construct() { + parent::__construct( + 'peptide_news_widget', + __( 'Peptide News', 'peptide-news' ), + array( + 'description' => __( 'Displays the latest peptide research news.', 'peptide-news' ), + 'classname' => 'peptide-news-widget', + ) + ); + } - public function widget( $args, $instance ) { - $title = apply_filters( 'widget_title', $instance['title'] ?? __( 'Peptide News', 'peptide-news' ) ); - $count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5; + public function widget( $args, $instance ) { + $title = apply_filters( 'widget_title', $instance['title'] ?? __( 'Peptide News', 'peptide-news' ) ); + $count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5; - echo $args['before_widget']; + echo $args['before_widget']; - if ( $title ) { - echo $args['before_title'] . esc_html( $title ) . $args['after_title']; - } + if ( $title ) { + echo $args['before_title'] . esc_html( $title ) . $args['after_title']; + } - $public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION ); - echo $public->render_shortcode( array( 'count' => $count ) ); + $public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION ); + echo $public->render_shortcode( array( 'count' => $count ) ); - echo $args['after_widget']; - } + echo $args['after_widget']; + } - public function form( $instance ) { - if ( ! is_array( $instance ) ) { - $instance = array(); - } - $title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' ); - $count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5; - ?> -

- - -

-

- - -

- +

+ + +

+

+ + +

+