diff --git a/admin/class-peptide-news-admin.php b/admin/class-peptide-news-admin.php
index 486a749..98a71b5 100644
--- a/admin/class-peptide-news-admin.php
+++ b/admin/class-peptide-news-admin.php
@@ -8,937 +8,937 @@
*/
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(
- '' . 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 '' . 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(
- '' . 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_html__( 'Comma-separated keywords for NewsAPI searches.', 'peptide-news' ) . '
';
- }
-
- public function render_rss_enabled_field() {
- $value = get_option( 'peptide_news_rss_enabled', 1 );
- printf(
- '' . 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(
- '' . 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(
- '' . 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_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_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_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(
- '' . 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_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(
- '' . 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 '';
- echo '
' . esc_html( get_admin_page_title() ) . ' ';
-
- // Fetch Now button.
- echo '
';
-
- // Backfill Sources button (fixes "news.google.com" source labels).
- echo '
';
- echo esc_html__( 'Fix Article Sources', 'peptide-news' );
- echo ' ';
- echo '
';
-
- // Generate AI Summaries button.
- echo '
';
- echo '
';
- echo esc_html__( 'Generate AI Summaries', 'peptide-news' );
- 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 '
';
-
- // 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 '' . esc_html__( 'All Levels', 'peptide-news' ) . ' ';
- echo 'Info ';
- echo 'Warning ';
- echo 'Error ';
- echo 'Debug ';
- echo ' ';
- echo '';
- echo '' . esc_html__( 'All Contexts', 'peptide-news' ) . ' ';
- echo 'Fetch ';
- echo 'LLM / AI ';
- echo 'Cron ';
- echo 'Admin ';
- echo 'General ';
- echo ' ';
- echo '' . esc_html__( 'Refresh', 'peptide-news' ) . ' ';
- echo '' . esc_html__( 'Clear Log', 'peptide-news' ) . ' ';
- echo ' ';
- echo '
';
-
- // Log table.
- echo '
';
- echo '
';
- echo '';
- echo '' . esc_html__( 'Time', 'peptide-news' ) . ' ';
- echo '' . esc_html__( 'Level', 'peptide-news' ) . ' ';
- echo '' . esc_html__( 'Context', 'peptide-news' ) . ' ';
- echo '' . esc_html__( 'Message', 'peptide-news' ) . ' ';
- echo ' ';
- echo '' . esc_html__( 'Loading...', 'peptide-news' ) . ' ';
- echo '
';
- echo '
';
-
- // Pagination.
- echo '';
-
- ?>
-
- ';
- }
-
- 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 '
';
+ foreach ( $options as $key => $label ) {
+ printf(
+ '%s ',
+ esc_attr( $key ),
+ selected( $value, $key, false ),
+ esc_html( $label )
+ );
+ }
+ 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(
+ '
%s',
+ 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(
+ '
%s',
+ 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(
+ '
%s',
+ 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(
+ '
%s',
+ 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 '
';
+ foreach ( $options as $key => $label ) {
+ printf(
+ '%s ',
+ esc_attr( $key ),
+ selected( $value, $key, false ),
+ esc_html( $label )
+ );
+ }
+ echo ' ';
+ }
+
+ public function render_filter_llm_enabled_field() {
+ $value = get_option( 'peptide_news_filter_llm_enabled', 0 );
+ printf(
+ '
%s',
+ 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(
+ '
%s',
+ 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 '
';
+
+ // Backfill Sources button (fixes "news.google.com" source labels).
+ echo '
';
+ echo esc_html__( 'Fix Article Sources', 'peptide-news' );
+ echo ' ';
+ echo '
';
+
+ // Generate AI Summaries button.
+ echo '
';
+ echo '
';
+ echo esc_html__( 'Generate AI Summaries', 'peptide-news' );
+ 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 '
';
+
+ // 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 '' . esc_html__( 'All Levels', 'peptide-news' ) . ' ';
+ echo 'Info ';
+ echo 'Warning ';
+ echo 'Error ';
+ echo 'Debug ';
+ echo ' ';
+ echo '';
+ echo '' . esc_html__( 'All Contexts', 'peptide-news' ) . ' ';
+ echo 'Fetch ';
+ echo 'LLM / AI ';
+ echo 'Cron ';
+ echo 'Admin ';
+ echo 'General ';
+ echo ' ';
+ echo '' . esc_html__( 'Refresh', 'peptide-news' ) . ' ';
+ echo '' . esc_html__( 'Clear Log', 'peptide-news' ) . ' ';
+ echo ' ';
+ echo '
';
+
+ // Log table.
+ echo '
';
+ echo '
';
+ echo '';
+ echo '' . esc_html__( 'Time', 'peptide-news' ) . ' ';
+ echo '' . esc_html__( 'Level', 'peptide-news' ) . ' ';
+ echo '' . esc_html__( 'Context', 'peptide-news' ) . ' ';
+ echo '' . esc_html__( 'Message', 'peptide-news' ) . ' ';
+ echo ' ';
+ echo '' . esc_html__( 'Loading...', 'peptide-news' ) . ' ';
+ echo '
';
+ echo '
';
+
+ // Pagination.
+ echo '';
+
+ ?>
+
+ ';
+ }
+
+ 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..01463b1 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-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( '/