Deploy 6796121 from main

This commit is contained in:
github-actions[bot]
2026-04-12 10:29:18 +00:00
parent 24d15ca938
commit 5d4e73b947
14 changed files with 3633 additions and 3621 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
*/ */
if ( ! defined( 'ABSPATH' ) ) { if ( ! defined( 'ABSPATH' ) ) {
exit; exit;
} }
$raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : ''; $raw_start = isset( $_GET['start_date'] ) ? sanitize_text_field( wp_unslash( $_GET['start_date'] ) ) : '';
@@ -27,126 +27,126 @@ $total_articles = count( $top_articles );
?> ?>
<div class="wrap peptide-news-dashboard"> <div class="wrap peptide-news-dashboard">
<h1><?php esc_html_e( 'Peptide News Analytics', 'peptide-news' ); ?></h1> <h1><?php esc_html_e( 'Peptide News Analytics', 'peptide-news' ); ?></h1>
<!-- Date range filter --> <!-- Date range filter -->
<form method="get" class="pn-date-filter"> <form method="get" class="pn-date-filter">
<input type="hidden" name="page" value="peptide-news-dashboard" /> <input type="hidden" name="page" value="peptide-news-dashboard" />
<label for="start_date"><?php esc_html_e( 'From:', 'peptide-news' ); ?></label> <label for="start_date"><?php esc_html_e( 'From:', 'peptide-news' ); ?></label>
<input type="date" id="start_date" name="start_date" value="<?php echo esc_attr( $start_date ); ?>" /> <input type="date" id="start_date" name="start_date" value="<?php echo esc_attr( $start_date ); ?>" />
<label for="end_date"><?php esc_html_e( 'To:', 'peptide-news' ); ?></label> <label for="end_date"><?php esc_html_e( 'To:', 'peptide-news' ); ?></label>
<input type="date" id="end_date" name="end_date" value="<?php echo esc_attr( $end_date ); ?>" /> <input type="date" id="end_date" name="end_date" value="<?php echo esc_attr( $end_date ); ?>" />
<button type="submit" class="button button-primary"><?php esc_html_e( 'Filter', 'peptide-news' ); ?></button> <button type="submit" class="button button-primary"><?php esc_html_e( 'Filter', 'peptide-news' ); ?></button>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=peptide-news-dashboard&start_date=' . urlencode( $start_date ) . '&end_date=' . urlencode( $end_date ) . '&export=csv' ), 'peptide_news_export_csv' ) ); ?>" class="button"><?php esc_html_e( 'Export CSV', 'peptide-news' ); ?></a> <a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin.php?page=peptide-news-dashboard&start_date=' . urlencode( $start_date ) . '&end_date=' . urlencode( $end_date ) . '&export=csv' ), 'peptide_news_export_csv' ) ); ?>" class="button"><?php esc_html_e( 'Export CSV', 'peptide-news' ); ?></a>
</form> </form>
<!-- Summary cards --> <!-- Summary cards -->
<div class="pn-summary-cards"> <div class="pn-summary-cards">
<div class="pn-card"> <div class="pn-card">
<h3><?php echo esc_html( number_format( $total_clicks ) ); ?></h3> <h3><?php echo esc_html( number_format( $total_clicks ) ); ?></h3>
<p><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></p> <p><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></p>
</div> </div>
<div class="pn-card"> <div class="pn-card">
<h3><?php echo esc_html( number_format( $total_unique ) ); ?></h3> <h3><?php echo esc_html( number_format( $total_unique ) ); ?></h3>
<p><?php esc_html_e( 'Unique Visitors', 'peptide-news' ); ?></p> <p><?php esc_html_e( 'Unique Visitors', 'peptide-news' ); ?></p>
</div> </div>
<div class="pn-card"> <div class="pn-card">
<h3><?php echo esc_html( $total_articles ); ?></h3> <h3><?php echo esc_html( $total_articles ); ?></h3>
<p><?php esc_html_e( 'Articles Clicked', 'peptide-news' ); ?></p> <p><?php esc_html_e( 'Articles Clicked', 'peptide-news' ); ?></p>
</div> </div>
<div class="pn-card"> <div class="pn-card">
<h3><?php echo $total_articles > 0 ? esc_html( round( $total_clicks / $total_articles, 1 ) ) : '0'; ?></h3> <h3><?php echo $total_articles > 0 ? esc_html( round( $total_clicks / $total_articles, 1 ) ) : '0'; ?></h3>
<p><?php esc_html_e( 'Avg Clicks / Article', 'peptide-news' ); ?></p> <p><?php esc_html_e( 'Avg Clicks / Article', 'peptide-news' ); ?></p>
</div> </div>
</div> </div>
<!-- Charts row --> <!-- Charts row -->
<div class="pn-charts-row"> <div class="pn-charts-row">
<div class="pn-chart-box pn-chart-wide"> <div class="pn-chart-box pn-chart-wide">
<h2><?php esc_html_e( 'Click Trends', 'peptide-news' ); ?></h2> <h2><?php esc_html_e( 'Click Trends', 'peptide-news' ); ?></h2>
<canvas id="pn-trends-chart" height="300"></canvas> <canvas id="pn-trends-chart" height="300"></canvas>
</div> </div>
</div> </div>
<div class="pn-charts-row"> <div class="pn-charts-row">
<div class="pn-chart-box"> <div class="pn-chart-box">
<h2><?php esc_html_e( 'Device Breakdown', 'peptide-news' ); ?></h2> <h2><?php esc_html_e( 'Device Breakdown', 'peptide-news' ); ?></h2>
<canvas id="pn-devices-chart" height="250"></canvas> <canvas id="pn-devices-chart" height="250"></canvas>
</div> </div>
<div class="pn-chart-box"> <div class="pn-chart-box">
<h2><?php esc_html_e( 'Source Performance', 'peptide-news' ); ?></h2> <h2><?php esc_html_e( 'Source Performance', 'peptide-news' ); ?></h2>
<canvas id="pn-sources-chart" height="250"></canvas> <canvas id="pn-sources-chart" height="250"></canvas>
</div> </div>
</div> </div>
<!-- Top articles table --> <!-- Top articles table -->
<div class="pn-table-section"> <div class="pn-table-section">
<h2><?php esc_html_e( 'Top Articles', 'peptide-news' ); ?></h2> <h2><?php esc_html_e( 'Top Articles', 'peptide-news' ); ?></h2>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
<thead> <thead>
<tr> <tr>
<th style="width:5%">#</th> <th style="width:5%">#</th>
<th style="width:45%"><?php esc_html_e( 'Article', 'peptide-news' ); ?></th> <th style="width:45%"><?php esc_html_e( 'Article', 'peptide-news' ); ?></th>
<th style="width:15%"><?php esc_html_e( 'Source', 'peptide-news' ); ?></th> <th style="width:15%"><?php esc_html_e( 'Source', 'peptide-news' ); ?></th>
<th style="width:15%"><?php esc_html_e( 'Categories', 'peptide-news' ); ?></th> <th style="width:15%"><?php esc_html_e( 'Categories', 'peptide-news' ); ?></th>
<th style="width:10%"><?php esc_html_e( 'Clicks', 'peptide-news' ); ?></th> <th style="width:10%"><?php esc_html_e( 'Clicks', 'peptide-news' ); ?></th>
<th style="width:10%"><?php esc_html_e( 'Unique', 'peptide-news' ); ?></th> <th style="width:10%"><?php esc_html_e( 'Unique', 'peptide-news' ); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ( empty( $top_articles ) ) : ?> <?php if ( empty( $top_articles ) ) : ?>
<tr><td colspan="6"><?php esc_html_e( 'No click data for this period.', 'peptide-news' ); ?></td></tr> <tr><td colspan="6"><?php esc_html_e( 'No click data for this period.', 'peptide-news' ); ?></td></tr>
<?php else : ?> <?php else : ?>
<?php foreach( $top_articles as $i => $article ) : ?> <?php foreach ( $top_articles as $i => $article ) : ?>
<tr> <tr>
<td><?php echo esc_html( $i + 1 ); ?></td> <td><?php echo esc_html( $i + 1 ); ?></td>
<td><a href="<?php echo esc_url( $article->source_url ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $article->title ); ?></a></td> <td><a href="<?php echo esc_url( $article->source_url ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $article->title ); ?></a></td>
<td><?php echo esc_html( $article->source ); ?></td> <td><?php echo esc_html( $article->source ); ?></td>
<td><?php echo esc_html( $article->categories ); ?></td> <td><?php echo esc_html( $article->categories ); ?></td>
<td><strong><?php echo esc_html( number_format( $article->total_clicks ) ); ?></strong></td> <td><strong><?php echo esc_html( number_format( $article->total_clicks ) ); ?></strong></td>
<td><?php echo esc_html( number_format( $article->total_unique ) ); ?></td> <td><?php echo esc_html( number_format( $article->total_unique ) ); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Popular topics table --> <!-- Popular topics table -->
<div class="pn-table-section"> <div class="pn-table-section">
<h2><?php esc_html_e( 'Popular Topics', 'peptide-news' ); ?></h2> <h2><?php esc_html_e( 'Popular Topics', 'peptide-news' ); ?></h2>
<table class="wp-list-table widefat fixed striped"> <table class="wp-list-table widefat fixed striped">
<thead> <thead>
<tr> <tr>
<th style="width:5%">#</th> <th style="width:5%">#</th>
<th style="width:45%"><?php esc_html_e( 'Topic', 'peptide-news' ); ?></th> <th style="width:45%"><?php esc_html_e( 'Topic', 'peptide-news' ); ?></th>
<th style="width:25%"><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></th> <th style="width:25%"><?php esc_html_e( 'Total Clicks', 'peptide-news' ); ?></th>
<th style="width:25%"><?php esc_html_e( 'Articles', 'peptide-news' ); ?></th> <th style="width:25%"><?php esc_html_e( 'Articles', 'peptide-news' ); ?></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if ( empty( $topics ) ) : ?> <?php if ( empty( $topics ) ) : ?>
<tr><td colspan="4"><?php esc_html_e( 'No topic data for this period.', 'peptide-news' ); ?></td></tr> <tr><td colspan="4"><?php esc_html_e( 'No topic data for this period.', 'peptide-news' ); ?></td></tr>
<?php else : ?> <?php else : ?>
<?php foreach( $topics as $i => $topic ) : ?> <?php foreach ( $topics as $i => $topic ) : ?>
<tr> <tr>
<td><?php echo esc_html( $i + 1 ); ?></td> <td><?php echo esc_html( $i + 1 ); ?></td>
<td><?php echo esc_html( $topic['topic'] ); ?></td> <td><?php echo esc_html( $topic['topic'] ); ?></td>
<td><?php echo esc_html( number_format( $topic['total_clicks'] ) ); ?></td> <td><?php echo esc_html( number_format( $topic['total_clicks'] ) ); ?></td>
<td><?php echo esc_html( $topic['article_count'] ); ?></td> <td><?php echo esc_html( $topic['article_count'] ); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<script> <script>
// Pass PHP data to JS for Chart.js rendering. // Pass PHP data to JS for Chart.js rendering.
var peptideNewsDashboardData = { var peptideNewsDashboardData = {
trends: <?php echo wp_json_encode( $trends ); ?>, trends: <?php echo wp_json_encode( $trends ); ?>,
devices: <?php echo wp_json_encode( $devices ); ?>, devices: <?php echo wp_json_encode( $devices ); ?>,
sources: <?php echo wp_json_encode( $sources ); ?> sources: <?php echo wp_json_encode( $sources ); ?>
}; };
</script> </script>

View File

@@ -8,180 +8,180 @@
*/ */
class Peptide_News_Activator { class Peptide_News_Activator {
/** /**
* Create custom tables and schedule cron. * Create custom tables and schedule cron.
*/ */
public static function activate() { public static function activate() {
self::create_tables(); self::create_tables();
self::set_default_options(); self::set_default_options();
self::schedule_cron(); self::schedule_cron();
// Create the log table (idempotent via dbDelta). // Create the log table (idempotent via dbDelta).
if ( class_exists( 'Peptide_News_Logger' ) ) { if ( class_exists( 'Peptide_News_Logger' ) ) {
Peptide_News_Logger::create_table(); Peptide_News_Logger::create_table();
} }
} }
/** /**
* Create the articles and analytics database tables. * Create the articles and analytics database tables.
*/ */
private static function create_tables() { private static function create_tables() {
global $wpdb; global $wpdb;
$charset_collate = $wpdb->get_charset_collate(); $charset_collate = $wpdb->get_charset_collate();
// Articles table — stores fetched news data for analysis. // Articles table — stores fetched news data for analysis.
$articles_table = $wpdb->prefix . 'peptide_news_articles'; $articles_table = $wpdb->prefix . 'peptide_news_articles';
$sql_articles = "CREATE TABLE {$articles_table} ( $sql_articles = "CREATE TABLE {$articles_table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
source VARCHAR(100) NOT NULL DEFAULT '', source VARCHAR(100) NOT NULL DEFAULT '',
source_url VARCHAR(2048) NOT NULL DEFAULT '', source_url VARCHAR(2048) NOT NULL DEFAULT '',
title TEXT NOT NULL, title TEXT NOT NULL,
excerpt TEXT NOT NULL, excerpt TEXT NOT NULL,
content LONGTEXT NOT NULL, content LONGTEXT NOT NULL,
author VARCHAR(255) NOT NULL DEFAULT '', author VARCHAR(255) NOT NULL DEFAULT '',
thumbnail_url VARCHAR(2048) NOT NULL DEFAULT '', thumbnail_url VARCHAR(2048) NOT NULL DEFAULT '',
thumbnail_local VARCHAR(512) NOT NULL DEFAULT '', thumbnail_local VARCHAR(512) NOT NULL DEFAULT '',
published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
categories VARCHAR(500) NOT NULL DEFAULT '', categories VARCHAR(500) NOT NULL DEFAULT '',
tags VARCHAR(500) NOT NULL DEFAULT '', tags VARCHAR(500) NOT NULL DEFAULT '',
language VARCHAR(10) NOT NULL DEFAULT 'en', language VARCHAR(10) NOT NULL DEFAULT 'en',
sentiment_score FLOAT DEFAULT NULL, sentiment_score FLOAT DEFAULT NULL,
ai_summary TEXT NOT NULL, ai_summary TEXT NOT NULL,
hash VARCHAR(64) NOT NULL DEFAULT '', hash VARCHAR(64) NOT NULL DEFAULT '',
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY hash (hash), UNIQUE KEY hash (hash),
KEY idx_published (published_at), KEY idx_published (published_at),
KEY idx_source (source), KEY idx_source (source),
KEY idx_active_published (is_active, published_at) KEY idx_active_published (is_active, published_at)
) {$charset_collate};"; ) {$charset_collate};";
// Click analytics table — one row per click event. // Click analytics table — one row per click event.
$clicks_table = $wpdb->prefix . 'peptide_news_clicks'; $clicks_table = $wpdb->prefix . 'peptide_news_clicks';
$sql_clicks = "CREATE TABLE {$clicks_table} ( $sql_clicks = "CREATE TABLE {$clicks_table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
article_id BIGINT(20) UNSIGNED NOT NULL, article_id BIGINT(20) UNSIGNED NOT NULL,
clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, clicked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_ip VARCHAR(45) NOT NULL DEFAULT '', user_ip VARCHAR(45) NOT NULL DEFAULT '',
user_agent TEXT NOT NULL, user_agent TEXT NOT NULL,
referrer_url VARCHAR(2048) NOT NULL DEFAULT '', referrer_url VARCHAR(2048) NOT NULL DEFAULT '',
page_url VARCHAR(2048) NOT NULL DEFAULT '', page_url VARCHAR(2048) NOT NULL DEFAULT '',
session_id VARCHAR(64) NOT NULL DEFAULT '', session_id VARCHAR(64) NOT NULL DEFAULT '',
user_id BIGINT(20) UNSIGNED DEFAULT NULL, user_id BIGINT(20) UNSIGNED DEFAULT NULL,
country VARCHAR(2) NOT NULL DEFAULT '', country VARCHAR(2) NOT NULL DEFAULT '',
device_type VARCHAR(20) NOT NULL DEFAULT '', device_type VARCHAR(20) NOT NULL DEFAULT '',
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_article (article_id), KEY idx_article (article_id),
KEY idx_clicked_at (clicked_at), KEY idx_clicked_at (clicked_at),
KEY idx_session (session_id), KEY idx_session (session_id),
KEY idx_article_date (article_id, clicked_at) KEY idx_article_date (article_id, clicked_at)
) {$charset_collate};"; ) {$charset_collate};";
// Daily aggregates table — for fast trend queries. // Daily aggregates table — for fast trend queries.
$daily_table = $wpdb->prefix . 'peptide_news_daily_stats'; $daily_table = $wpdb->prefix . 'peptide_news_daily_stats';
$sql_daily = "CREATE TABLE {$daily_table} ( $sql_daily = "CREATE TABLE {$daily_table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
article_id BIGINT(20) UNSIGNED NOT NULL, article_id BIGINT(20) UNSIGNED NOT NULL,
stat_date DATE NOT NULL, stat_date DATE NOT NULL,
click_count INT UNSIGNED NOT NULL DEFAULT 0, click_count INT UNSIGNED NOT NULL DEFAULT 0,
unique_visitors INT UNSIGNED NOT NULL DEFAULT 0, unique_visitors INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY idx_article_date (article_id, stat_date), UNIQUE KEY idx_article_date (article_id, stat_date),
KEY idx_date (stat_date) KEY idx_date (stat_date)
) {$charset_collate};"; ) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql_articles ); dbDelta( $sql_articles );
dbDelta( $sql_clicks ); dbDelta( $sql_clicks );
dbDelta( $sql_daily ); dbDelta( $sql_daily );
// Add foreign key constraints if they don't exist. // Add foreign key constraints if they don't exist.
self::add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ); 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. * Set sensible default option values.
*/ */
private static function set_default_options() { private static function set_default_options() {
$defaults = array( $defaults = array(
'fetch_interval' => 'twicedaily', 'fetch_interval' => 'twicedaily',
'articles_count' => 10, 'articles_count' => 10,
'newsapi_key' => '', 'newsapi_key' => '',
'newsapi_enabled' => 0, 'newsapi_enabled' => 0,
'rss_feeds' => implode( "\n", array( 'rss_feeds' => implode( "\n", array(
'https://news.google.com/rss/search?q=peptide+research&hl=en-US&gl=US&ceid=US:en', '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', 'https://pubmed.ncbi.nlm.nih.gov/rss/search/1sUlVGjEXm1YN9QiGPblv_-bwB51JCtyLwVX85CVGDKCHFFR9Q/?limit=20&utm_campaign=pubmed-2&fc=20220101000000',
) ), ) ),
'rss_enabled' => 1, 'rss_enabled' => 1,
'search_keywords' => 'peptide, peptides, peptide therapy, peptide research, BPC-157, Thymosin, GHK-Cu', 'search_keywords' => 'peptide, peptides, peptide therapy, peptide research, BPC-157, Thymosin, GHK-Cu',
'thumbnail_fallback' => PEPTIDE_NEWS_PLUGIN_URL . 'public/images/default-thumb.png', 'thumbnail_fallback' => PEPTIDE_NEWS_PLUGIN_URL . 'public/images/default-thumb.png',
'article_retention' => 90, 'article_retention' => 90,
'analytics_retention' => 365, 'analytics_retention' => 365,
'anonymize_ip' => 1, 'anonymize_ip' => 1,
'llm_enabled' => 0, 'llm_enabled' => 0,
'openrouter_api_key' => '', 'openrouter_api_key' => '',
'llm_keywords_model' => 'google/gemini-2.0-flash-001', 'llm_keywords_model' => 'google/gemini-2.0-flash-001',
'llm_summary_model' => 'google/gemini-2.0-flash-001', 'llm_summary_model' => 'google/gemini-2.0-flash-001',
'llm_max_articles' => 10, 'llm_max_articles' => 10,
); );
foreach ( $defaults as $key => $value ) { foreach ( $defaults as $key => $value ) {
if ( false === get_option( "peptide_news_{$key}" ) ) { if ( false === get_option( "peptide_news_{$key}" ) ) {
add_option( "peptide_news_{$key}", $value ); add_option( "peptide_news_{$key}", $value );
} }
} }
} }
/** /**
* Schedule the cron event based on the configured interval. * Schedule the cron event based on the configured interval.
*/ */
private static function schedule_cron() { private static function schedule_cron() {
$interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' ); $interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) { if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) {
wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' ); wp_schedule_event( time(), $interval, 'peptide_news_cron_fetch' );
} }
} }
/** /**
* Add foreign key constraints to tables. * Add foreign key constraints to tables.
*/ */
private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) { private static function add_foreign_key_constraints( $articles_table, $clicks_table, $daily_table ) {
global $wpdb; global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$existing_clicks_fk = $wpdb->get_var( $existing_clicks_fk = $wpdb->get_var(
$wpdb->prepare( $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", "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 $clicks_table
) )
); );
if ( empty( $existing_clicks_fk ) ) { if ( empty( $existing_clicks_fk ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( $wpdb->query(
"ALTER TABLE `{$clicks_table}` ADD CONSTRAINT `fk_clicks_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" "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 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$existing_stats_fk = $wpdb->get_var( $existing_stats_fk = $wpdb->get_var(
$wpdb->prepare( $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", "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 $daily_table
) )
); );
if ( empty( $existing_stats_fk ) ) { if ( empty( $existing_stats_fk ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( $wpdb->query(
"ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE" "ALTER TABLE `{$daily_table}` ADD CONSTRAINT `fk_stats_article_id` FOREIGN KEY (`article_id`) REFERENCES `{$articles_table}`(`id`) ON DELETE CASCADE"
); );
} }
} }
} }

View File

@@ -9,342 +9,342 @@
*/ */
class Peptide_News_Analytics { class Peptide_News_Analytics {
/** /**
* Record a click event for an article. * Record a click event for an article.
* *
* @param int $article_id * @param int $article_id
* @param array $meta Additional context (IP, UA, referrer, etc.). * @param array $meta Additional context (IP, UA, referrer, etc.).
* @return bool * @return bool
*/ */
public static function record_click( $article_id, $meta = array() ) { public static function record_click( $article_id, $meta = array() ) {
global $wpdb; 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. // Skip recording if no valid IP is available.
if ( null === $ip ) { if ( null === $ip ) {
return false; return false;
} }
// Optionally anonymize IP (GDPR-friendly). // Optionally anonymize IP (GDPR-friendly).
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) { if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
$ip = self::anonymize_ip( $ip ); $ip = self::anonymize_ip( $ip );
} }
$data = array( $data = array(
'article_id' => absint( $article_id ), 'article_id' => absint( $article_id ),
'clicked_at' => current_time( 'mysql' ), 'clicked_at' => current_time( 'mysql' ),
'user_ip' => sanitize_text_field( $ip ), 'user_ip' => sanitize_text_field( $ip ),
'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ), 'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ),
'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ), 'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ),
'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ), 'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ),
'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ), 'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ),
'user_id' => is_user_logged_in() ? get_current_user_id() : null, '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'] : '' ) ), '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. // Update daily aggregate.
if ( false !== $result ) { if ( false !== $result ) {
self::update_daily_stats( $article_id, $ip ); self::update_daily_stats( $article_id, $ip );
} }
return false !== $result; return false !== $result;
} }
/** /**
* Update daily aggregated stats. * Update daily aggregated stats.
* *
* @param int $article_id * @param int $article_id
* @param string $ip Anonymized IP for unique visitor counting. * @param string $ip Anonymized IP for unique visitor counting.
*/ */
private static function update_daily_stats( $article_id, $ip ) { private static function update_daily_stats( $article_id, $ip ) {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats'; $table = $wpdb->prefix . 'peptide_news_daily_stats';
$today = current_time( 'Y-m-d' ); $today = current_time( 'Y-m-d' );
$clicks_tbl = $wpdb->prefix . 'peptide_news_clicks'; $clicks_tbl = $wpdb->prefix . 'peptide_news_clicks';
// Upsert daily click count. // Upsert daily click count.
$existing = $wpdb->get_row( $wpdb->prepare( $existing = $wpdb->get_row( $wpdb->prepare(
"SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s", "SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s",
$article_id, $article_id,
$today $today
) ); ) );
// Count unique IPs for today. // Count unique IPs for today.
$unique = $wpdb->get_var( $wpdb->prepare( $unique = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s", "SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s",
$article_id, $article_id,
$today $today
) ); ) );
if ( $existing ) { if ( $existing ) {
$wpdb->update( $wpdb->update(
$table, $table,
array( array(
'click_count' => $existing->click_count + 1, 'click_count' => $existing->click_count + 1,
'unique_visitors' => (int) $unique, 'unique_visitors' => (int) $unique,
), ),
array( 'id' => $existing->id ), array( 'id' => $existing->id ),
array( '%d', '%d' ), array( '%d', '%d' ),
array( '%d' ) array( '%d' )
); );
} else { } else {
$wpdb->insert( $wpdb->insert(
$table, $table,
array( array(
'article_id' => $article_id, 'article_id' => $article_id,
'stat_date' => $today, 'stat_date' => $today,
'click_count' => 1, 'click_count' => 1,
'unique_visitors' => 1, 'unique_visitors' => 1,
) )
); );
} }
} }
/** /**
* Get top articles by click count within a date range. * Get top articles by click count within a date range.
* *
* @param string $start_date Y-m-d * @param string $start_date Y-m-d
* @param string $end_date Y-m-d * @param string $end_date Y-m-d
* @param int $limit * @param int $limit
* @return array * @return array
*/ */
public static function get_top_articles( $start_date, $end_date, $limit = 20 ) { public static function get_top_articles( $start_date, $end_date, $limit = 20 ) {
global $wpdb; global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles'; $articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare( return $wpdb->get_results( $wpdb->prepare(
"SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at, "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.click_count) AS total_clicks,
SUM(s.unique_visitors) AS total_unique SUM(s.unique_visitors) AS total_unique
FROM {$stats_table} s FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s WHERE s.stat_date BETWEEN %s AND %s
GROUP BY a.id GROUP BY a.id
ORDER BY total_clicks DESC ORDER BY total_clicks DESC
LIMIT %d", LIMIT %d",
$start_date, $start_date,
$end_date, $end_date,
$limit $limit
) ); ) );
} }
/** /**
* Get click trends over time (daily totals). * Get click trends over time (daily totals).
* *
* @param string $start_date * @param string $start_date
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function get_click_trends( $start_date, $end_date ) { public static function get_click_trends( $start_date, $end_date ) {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats'; $table = $wpdb->prefix . 'peptide_news_daily_stats';
return $wpdb->get_results( $wpdb->prepare( return $wpdb->get_results( $wpdb->prepare(
"SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique "SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique
FROM {$table} FROM {$table}
WHERE stat_date BETWEEN %s AND %s WHERE stat_date BETWEEN %s AND %s
GROUP BY stat_date GROUP BY stat_date
ORDER BY stat_date ASC", ORDER BY stat_date ASC",
$start_date, $start_date,
$end_date $end_date
) ); ) );
} }
/** /**
* Get popular topics/categories by click volume. * Get popular topics/categories by click volume.
* *
* @param string $start_date * @param string $start_date
* @param string $end_date * @param string $end_date
* @param int $limit * @param int $limit
* @return array * @return array
*/ */
public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) { public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) {
global $wpdb; global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles'; $articles_table = $wpdb->prefix . 'peptide_news_articles';
// Get articles with their click counts. // Get articles with their click counts.
$rows = $wpdb->get_results( $wpdb->prepare( $rows = $wpdb->get_results( $wpdb->prepare(
"SELECT a.categories, a.title, SUM(s.click_count) AS clicks "SELECT a.categories, a.title, SUM(s.click_count) AS clicks
FROM {$stats_table} s FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s AND a.categories != '' WHERE s.stat_date BETWEEN %s AND %s AND a.categories != ''
GROUP BY a.id GROUP BY a.id
ORDER BY clicks DESC", ORDER BY clicks DESC",
$start_date, $start_date,
$end_date $end_date
) ); ) );
// Aggregate by category. // Aggregate by category.
$topics = array(); $topics = array();
foreach ( $rows as $row ) { foreach ( $rows as $row ) {
$cats = array_map( 'trim', explode( ',', $row->categories ) ); $cats = array_map( 'trim', explode( ',', $row->categories ) );
foreach ( $cats as $cat ) { foreach ( $cats as $cat ) {
if ( empty( $cat ) ) { if ( empty( $cat ) ) {
continue; continue;
} }
$cat_lower = strtolower( $cat ); $cat_lower = strtolower( $cat );
if ( ! isset( $topics[ $cat_lower ] ) ) { if ( ! isset( $topics[ $cat_lower ] ) ) {
$topics[ $cat_lower ] = array( $topics[ $cat_lower ] = array(
'topic' => $cat, 'topic' => $cat,
'total_clicks' => 0, 'total_clicks' => 0,
'article_count' => 0, 'article_count' => 0,
); );
} }
$topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks; $topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks;
$topics[ $cat_lower ]['article_count'] += 1; $topics[ $cat_lower ]['article_count'] += 1;
} }
} }
// Sort by clicks. // Sort by clicks.
usort( $topics, function ( $a, $b ) { usort( $topics, function ( $a, $b ) {
return $b['total_clicks'] - $a['total_clicks']; return $b['total_clicks'] - $a['total_clicks'];
} ); } );
return array_slice( $topics, 0, $limit ); return array_slice( $topics, 0, $limit );
} }
/** /**
* Get device breakdown stats. * Get device breakdown stats.
* *
* @param string $start_date * @param string $start_date
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function get_device_breakdown( $start_date, $end_date ) { public static function get_device_breakdown( $start_date, $end_date ) {
global $wpdb; global $wpdb;
$table = $wpdb->prefix . 'peptide_news_clicks'; $table = $wpdb->prefix . 'peptide_news_clicks';
return $wpdb->get_results( $wpdb->prepare( return $wpdb->get_results( $wpdb->prepare(
"SELECT device_type, COUNT(*) AS click_count "SELECT device_type, COUNT(*) AS click_count
FROM {$table} FROM {$table}
WHERE DATE(clicked_at) BETWEEN %s AND %s WHERE DATE(clicked_at) BETWEEN %s AND %s
GROUP BY device_type GROUP BY device_type
ORDER BY click_count DESC", ORDER BY click_count DESC",
$start_date, $start_date,
$end_date $end_date
) ); ) );
} }
/** /**
* Get source performance stats. * Get source performance stats.
* *
* @param string $start_date * @param string $start_date
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function get_source_performance( $start_date, $end_date ) { public static function get_source_performance( $start_date, $end_date ) {
global $wpdb; global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats'; $stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles'; $articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare( return $wpdb->get_results( $wpdb->prepare(
"SELECT a.source, "SELECT a.source,
COUNT(DISTINCT a.id) AS article_count, COUNT(DISTINCT a.id) AS article_count,
SUM(s.click_count) AS total_clicks, 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 ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article
FROM {$stats_table} s FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s WHERE s.stat_date BETWEEN %s AND %s
GROUP BY a.source GROUP BY a.source
ORDER BY total_clicks DESC", ORDER BY total_clicks DESC",
$start_date, $start_date,
$end_date $end_date
) ); ) );
} }
/** /**
* Export click data as CSV-ready array. * Export click data as CSV-ready array.
* *
* @param string $start_date * @param string $start_date
* @param string $end_date * @param string $end_date
* @return array * @return array
*/ */
public static function export_clicks_csv( $start_date, $end_date ) { public static function export_clicks_csv( $start_date, $end_date ) {
global $wpdb; global $wpdb;
$clicks_table = $wpdb->prefix . 'peptide_news_clicks'; $clicks_table = $wpdb->prefix . 'peptide_news_clicks';
$articles_table = $wpdb->prefix . 'peptide_news_articles'; $articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare( return $wpdb->get_results( $wpdb->prepare(
"SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories, "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 c.referrer_url, c.page_url, c.device_type, c.session_id
FROM {$clicks_table} c FROM {$clicks_table} c
INNER JOIN {$articles_table} a ON a.id = c.article_id INNER JOIN {$articles_table} a ON a.id = c.article_id
WHERE DATE(c.clicked_at) BETWEEN %s AND %s WHERE DATE(c.clicked_at) BETWEEN %s AND %s
ORDER BY c.clicked_at DESC", ORDER BY c.clicked_at DESC",
$start_date, $start_date,
$end_date $end_date
), ARRAY_A ); ), ARRAY_A );
} }
/** /**
* Get the client IP address. * Get the client IP address.
* *
* @return string|null * @return string|null
*/ */
private static function get_client_ip() { private static function get_client_ip() {
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' ); $ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
foreach ( $ip_keys as $key ) { foreach ( $ip_keys as $key ) {
if ( ! empty( $_SERVER[ $key ] ) ) { if ( ! empty( $_SERVER[ $key ] ) ) {
$ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ); $ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
$ip = trim( $ip[0] ); $ip = trim( $ip[0] );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return $ip; return $ip;
} }
} }
} }
return null; return null;
} }
/** /**
* Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6). * Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6).
* *
* @param string $ip * @param string $ip
* @return string * @return string
*/ */
private static function anonymize_ip( $ip ) { private static function anonymize_ip( $ip ) {
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return preg_replace( '/\.\d+$/', '.0', $ip ); return preg_replace( '/\.\d+$/', '.0', $ip );
} }
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) { if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000'; return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':0000';
} }
return '0.0.0.0'; return '0.0.0.0';
} }
/** /**
* Simple device detection from user agent. * Simple device detection from user agent.
* *
* @param string $ua * @param string $ua
* @return string * @return string
*/ */
private static function detect_device( $ua ) { private static function detect_device( $ua ) {
$ua = strtolower( $ua ); $ua = strtolower( $ua );
if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) { if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) {
return 'tablet'; return 'tablet';
} }
if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) { if ( preg_match( '/(mobile|android|iphone|ipod|phone|blackberry|opera mini|iemobile)/', $ua ) ) {
return 'mobile'; return 'mobile';
} }
if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) { if ( preg_match( '/(bot|crawl|spider|slurp|wget|curl)/', $ua ) ) {
return 'bot'; return 'bot';
} }
return 'desktop'; return 'desktop';
} }
} }

View File

@@ -398,8 +398,7 @@ class Peptide_News_Content_Filter {
// Use XML-style delimiters to isolate untrusted article data from the prompt. // Use XML-style delimiters to isolate untrusted article data from the prompt.
// This mitigates prompt injection by clearly separating instructions from data. // This mitigates prompt injection by clearly separating instructions from data.
return $prompt = "You are a content classifier. Your ONLY task is to classify the article below.\n" .
"You are a content classifier. Your ONLY task is to classify the article below.\n" .
"Respond with EXACTLY one word: EDITORIAL or PROMOTIONAL.\n" . "Respond with EXACTLY one word: EDITORIAL or PROMOTIONAL.\n" .
"EDITORIAL = genuine news, research, or journalism.\n" . "EDITORIAL = genuine news, research, or journalism.\n" .
"PROMOTIONAL = press releases, ads, sponsored content, product launches, " . "PROMOTIONAL = press releases, ads, sponsored content, product launches, " .
@@ -412,6 +411,7 @@ class Peptide_News_Content_Filter {
sprintf( "<content>%s</content>\n", $content_trimmed ) . sprintf( "<content>%s</content>\n", $content_trimmed ) .
"</article_to_classify>\n\n" . "</article_to_classify>\n\n" .
"Classification (one word only):"; "Classification (one word only):";
return $prompt;
} }
/** /**

View File

@@ -8,7 +8,7 @@
*/ */
class Peptide_News_Deactivator { class Peptide_News_Deactivator {
public static function deactivate() { public static function deactivate() {
wp_clear_scheduled_hook( 'peptide_news_cron_fetch' ); wp_clear_scheduled_hook( 'peptide_news_cron_fetch' );
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,109 +12,109 @@
class Peptide_News_Loader { class Peptide_News_Loader {
/** /**
* The array of actions registered with WordPress. * The array of actions registered with WordPress.
* *
* @since 1.0.0 * @since 1.0.0
* @access protected * @access protected
* @var array $actions The actions registered with WordPress to fire when the plugin loads. * @var array $actions The actions registered with WordPress to fire when the plugin loads.
*/ */
protected $actions; protected $actions;
/** /**
* The array of filters registered with WordPress. * The array of filters registered with WordPress.
* *
* @since 1.0.0 * @since 1.0.0
* @access protected * @access protected
* @var array $filters The filters registered with WordPress. * @var array $filters The filters registered with WordPress.
*/ */
protected $filters; protected $filters;
/** /**
* Initialize the collections used to maintain the actions and filters. * Initialize the collections used to maintain the actions and filters.
* *
* @since 1.0.0 * @since 1.0.0
*/ */
public function __construct() { public function __construct() {
$this->actions = array(); $this->actions = array();
$this->filters = array(); $this->filters = array();
} }
/** /**
* Add a new action to the collection to be registered with WordPress. * Add a new action to the collection to be registered with WordPress.
* *
* @since 1.0.0 * @since 1.0.0
* @param string $hook The name of the WordPress action that is being registered. * @param string $hook The name of the WordPress action that is being registered.
* @param object $component A reference to the instance of the object on which the action is defined. * @param object $component A reference to the instance of the object on which the action is defined.
* @param string $callback The name of the function definition on the $component. * @param string $callback The name of the function definition on the $component.
* @param int $priority Optional. The priority at which the function should be fired. Default is 10. * @param int $priority Optional. The priority at which the function should be fired. Default is 10.
* @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1.
*/ */
public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
$this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args ); $this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args );
} }
/** /**
* Add a new filter to the collection to be registered with WordPress. * Add a new filter to the collection to be registered with WordPress.
* *
* @since 1.0.0 * @since 1.0.0
* @param string $hook The name of the WordPress filter that is being registered. * @param string $hook The name of the WordPress filter that is being registered.
* @param object $component A reference to the instance of the object on which the filter is defined. * @param object $component A reference to the instance of the object on which the filter is defined.
* @param string $callback The name of the function definition on the $component. * @param string $callback The name of the function definition on the $component.
* @param int $priority Optional. The priority at which the function should be fired. Default is 10. * @param int $priority Optional. The priority at which the function should be fired. Default is 10.
* @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1. * @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1.
*/ */
public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) { public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
$this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args ); $this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args );
} }
/** /**
* A utility function that is used to register the actions and hooks into a single collection. * A utility function that is used to register the actions and hooks into a single collection.
* *
* @since 1.0.0 * @since 1.0.0
* @access private * @access private
* @param array $hooks The collection of hooks that is being registered (actions or filters). * @param array $hooks The collection of hooks that is being registered (actions or filters).
* @param string $hook The name of the WordPress filter that is being registered. * @param string $hook The name of the WordPress filter that is being registered.
* @param object $component A reference to the instance of the object on which the filter is defined. * @param object $component A reference to the instance of the object on which the filter is defined.
* @param string $callback The name of the function definition on the $component. * @param string $callback The name of the function definition on the $component.
* @param int $priority The priority at which the function should be fired. * @param int $priority The priority at which the function should be fired.
* @param int $accepted_args The number of arguments that should be passed to the $callback. * @param int $accepted_args The number of arguments that should be passed to the $callback.
* @return array The collection of actions and filters registered with WordPress. * @return array The collection of actions and filters registered with WordPress.
*/ */
private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) { private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) {
$hooks[] = array( $hooks[] = array(
'hook' => $hook, 'hook' => $hook,
'component' => $component, 'component' => $component,
'callback' => $callback, 'callback' => $callback,
'priority' => $priority, 'priority' => $priority,
'accepted_args' => $accepted_args, 'accepted_args' => $accepted_args,
); );
return $hooks; return $hooks;
} }
/** /**
* Register the filters and actions with WordPress. * Register the filters and actions with WordPress.
* *
* @since 1.0.0 * @since 1.0.0
*/ */
public function run() { public function run() {
foreach ( $this->filters as $hook ) { foreach ( $this->filters as $hook ) {
add_filter( add_filter(
$hook['hook'], $hook['hook'],
array( $hook['component'], $hook['callback'] ), array( $hook['component'], $hook['callback'] ),
$hook['priority'], $hook['priority'],
$hook['accepted_args'] $hook['accepted_args']
); );
} }
foreach ( $this->actions as $hook ) { foreach ( $this->actions as $hook ) {
add_action( add_action(
$hook['hook'], $hook['hook'],
array( $hook['component'], $hook['callback'] ), array( $hook['component'], $hook['callback'] ),
$hook['priority'], $hook['priority'],
$hook['accepted_args'] $hook['accepted_args']
); );
} }
} }
} }

View File

@@ -9,246 +9,246 @@
*/ */
class Peptide_News_Logger { class Peptide_News_Logger {
/** Log level constants. */ /** Log level constants. */
const LEVEL_INFO = 'info'; const LEVEL_INFO = 'info';
const LEVEL_WARNING = 'warning'; const LEVEL_WARNING = 'warning';
const LEVEL_ERROR = 'error'; const LEVEL_ERROR = 'error';
const LEVEL_DEBUG = 'debug'; const LEVEL_DEBUG = 'debug';
/** Maximum rows kept in the log table (auto-pruned on write). */ /** Maximum rows kept in the log table (auto-pruned on write). */
const MAX_ROWS = 2000; const MAX_ROWS = 2000;
/** /**
* Return the log table name. * Return the log table name.
*/ */
public static function table() { public static function table() {
global $wpdb; global $wpdb;
return $wpdb->prefix . 'peptide_news_log'; return $wpdb->prefix . 'peptide_news_log';
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Writing * Writing
* ----------------------------------------------------------------*/ * ----------------------------------------------------------------*/
/** /**
* Write a log entry. * Write a log entry.
* *
* @param string $level One of the LEVEL_* constants. * @param string $level One of the LEVEL_* constants.
* @param string $message Human-readable message. * @param string $message Human-readable message.
* @param string $context Category tag (fetch, llm, admin, cron, general). * @param string $context Category tag (fetch, llm, admin, cron, general).
*/ */
public static function log( $level, $message, $context = 'general' ) { public static function log( $level, $message, $context = 'general' ) {
global $wpdb; global $wpdb;
$wpdb->insert( $wpdb->insert(
self::table(), self::table(),
array( array(
'level' => $level, 'level' => $level,
'context' => $context, 'context' => $context,
'message' => $message, 'message' => $message,
'created_at' => current_time( 'mysql' ), 'created_at' => current_time( 'mysql' ),
), ),
array( '%s', '%s', '%s', '%s' ) array( '%s', '%s', '%s', '%s' )
); );
// Auto-prune old entries. // Auto-prune old entries.
self::maybe_prune(); self::maybe_prune();
} }
/** Convenience: info level. */ /** Convenience: info level. */
public static function info( $message, $context = 'general' ) { public static function info( $message, $context = 'general' ) {
self::log( self::LEVEL_INFO, $message, $context ); self::log( self::LEVEL_INFO, $message, $context );
} }
/** Convenience: warning level. */ /** Convenience: warning level. */
public static function warning( $message, $context = 'general' ) { public static function warning( $message, $context = 'general' ) {
self::log( self::LEVEL_WARNING, $message, $context ); self::log( self::LEVEL_WARNING, $message, $context );
} }
/** Convenience: error level. */ /** Convenience: error level. */
public static function error( $message, $context = 'general' ) { public static function error( $message, $context = 'general' ) {
self::log( self::LEVEL_ERROR, $message, $context ); self::log( self::LEVEL_ERROR, $message, $context );
} }
/** Convenience: debug level (only writes when WP_DEBUG is on). */ /** Convenience: debug level (only writes when WP_DEBUG is on). */
public static function debug( $message, $context = 'general' ) { public static function debug( $message, $context = 'general' ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::log( self::LEVEL_DEBUG, $message, $context ); self::log( self::LEVEL_DEBUG, $message, $context );
} }
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Reading * Reading
* ----------------------------------------------------------------*/ * ----------------------------------------------------------------*/
/** /**
* Retrieve log entries. * Retrieve log entries.
* *
* @param array $args { * @param array $args {
* Optional. Query arguments. * Optional. Query arguments.
* *
* @type string $level Filter by level. * @type string $level Filter by level.
* @type string $context Filter by context. * @type string $context Filter by context.
* @type int $limit Number of rows. * @type int $limit Number of rows.
* @type int $offset Pagination offset. * @type int $offset Pagination offset.
* } * }
* @return array { 'rows' => array, 'total' => int } * @return array { 'rows' => array, 'total' => int }
*/ */
public static function get_logs( $args = array() ) { public static function get_logs( $args = array() ) {
global $wpdb; global $wpdb;
$defaults = array( $defaults = array(
'level' => '', 'level' => '',
'context' => '', 'context' => '',
'limit' => 50, 'limit' => 50,
'offset' => 0, 'offset' => 0,
); );
$args = wp_parse_args( $args, $defaults ); $args = wp_parse_args( $args, $defaults );
$table = self::table(); $table = self::table();
$where = array( '1=1' ); $where = array( '1=1' );
$vals = array(); $vals = array();
if ( ! empty( $args['level'] ) ) { if ( ! empty( $args['level'] ) ) {
$where[] = 'level = %s'; $where[] = 'level = %s';
$vals[] = $args['level']; $vals[] = $args['level'];
} }
if ( ! empty( $args['context'] ) ) { if ( ! empty( $args['context'] ) ) {
$where[] = 'context = %s'; $where[] = 'context = %s';
$vals[] = $args['context']; $vals[] = $args['context'];
} }
$where_sql = implode( ' AND ', $where ); $where_sql = implode( ' AND ', $where );
// Total count. // Total count.
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
$total = empty( $vals ) $total = empty( $vals )
? (int) $wpdb->get_var( $count_sql ) ? (int) $wpdb->get_var( $count_sql )
: (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) ); : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, $vals ) );
// Rows. // Rows.
$order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d"; $order_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
$vals[] = absint( $args['limit'] ); $vals[] = absint( $args['limit'] );
$vals[] = absint( $args['offset'] ); $vals[] = absint( $args['offset'] );
$rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) ); $rows = $wpdb->get_results( $wpdb->prepare( $order_sql, $vals ) );
return array( return array(
'rows' => $rows ? $rows : array(), 'rows' => $rows ? $rows : array(),
'total' => $total, 'total' => $total,
); );
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Maintenance * Maintenance
* ----------------------------------------------------------------*/ * ----------------------------------------------------------------*/
/** /**
* Remove rows beyond the MAX_ROWS cap. * Remove rows beyond the MAX_ROWS cap.
*/ */
private static function maybe_prune() { private static function maybe_prune() {
global $wpdb; global $wpdb;
$table = self::table(); $table = self::table();
// Only prune every ~50 writes (probabilistic). // Only prune every ~50 writes (probabilistic).
if ( wp_rand( 1, 50 ) !== 1 ) { if ( wp_rand( 1, 50 ) !== 1 ) {
return; return;
} }
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
if ( $count > self::MAX_ROWS ) { if ( $count > self::MAX_ROWS ) {
$delete_count = $count - self::MAX_ROWS; $delete_count = $count - self::MAX_ROWS;
$wpdb->query( $wpdb->prepare( $wpdb->query( $wpdb->prepare(
"DELETE FROM {$table} ORDER BY id ASC LIMIT %d", "DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
$delete_count $delete_count
) ); ) );
} }
} }
/** /**
* Clear all log entries. * Clear all log entries.
*/ */
public static function clear() { public static function clear() {
global $wpdb; global $wpdb;
$wpdb->query( "TRUNCATE TABLE " . self::table() ); $wpdb->query( "TRUNCATE TABLE " . self::table() );
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* AJAX handlers * AJAX handlers
* ----------------------------------------------------------------*/ * ----------------------------------------------------------------*/
/** /**
* AJAX: fetch paginated log entries. * AJAX: fetch paginated log entries.
*/ */
public static function ajax_get_logs() { public static function ajax_get_logs() {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 ); wp_send_json_error( 'Unauthorized', 403 );
} }
$page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1; $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
$level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : ''; $level = isset( $_POST['level'] ) ? sanitize_text_field( $_POST['level'] ) : '';
$context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : ''; $context = isset( $_POST['context'] ) ? sanitize_text_field( $_POST['context'] ) : '';
$per_page = 50; $per_page = 50;
$result = self::get_logs( array( $result = self::get_logs( array(
'level' => $level, 'level' => $level,
'context' => $context, 'context' => $context,
'limit' => $per_page, 'limit' => $per_page,
'offset' => ( $page - 1 ) * $per_page, 'offset' => ( $page - 1 ) * $per_page,
) ); ) );
wp_send_json_success( array( wp_send_json_success( array(
'rows' => $result['rows'], 'rows' => $result['rows'],
'total' => $result['total'], 'total' => $result['total'],
'page' => $page, 'page' => $page,
'total_pages' => ceil( $result['total'] / $per_page ), 'total_pages' => ceil( $result['total'] / $per_page ),
) ); ) );
} }
/** /**
* AJAX: clear all log entries. * AJAX: clear all log entries.
*/ */
public static function ajax_clear_logs() { public static function ajax_clear_logs() {
check_ajax_referer( 'peptide_news_admin', 'nonce' ); check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 ); wp_send_json_error( 'Unauthorized', 403 );
} }
self::clear(); self::clear();
self::info( 'Log cleared by admin.', 'admin' ); self::info( 'Log cleared by admin.', 'admin' );
wp_send_json_success( array( 'message' => 'Log cleared.' ) ); wp_send_json_success( array( 'message' => 'Log cleared.' ) );
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
* Schema * Schema
* ----------------------------------------------------------------*/ * ----------------------------------------------------------------*/
/** /**
* Create the log table (called from activator / upgrade check). * Create the log table (called from activator / upgrade check).
*/ */
public static function create_table() { public static function create_table() {
global $wpdb; global $wpdb;
$table = self::table(); $table = self::table();
$charset_collate = $wpdb->get_charset_collate(); $charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} ( $sql = "CREATE TABLE {$table} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
level VARCHAR(10) NOT NULL DEFAULT 'info', level VARCHAR(10) NOT NULL DEFAULT 'info',
context VARCHAR(30) NOT NULL DEFAULT 'general', context VARCHAR(30) NOT NULL DEFAULT 'general',
message TEXT NOT NULL, message TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_level (level), KEY idx_level (level),
KEY idx_context (context), KEY idx_context (context),
KEY idx_created (created_at) KEY idx_created (created_at)
) {$charset_collate};"; ) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql ); dbDelta( $sql );
} }
} }

View File

@@ -17,418 +17,418 @@
*/ */
class Peptide_News_Rest_API { class Peptide_News_Rest_API {
const API_NAMESPACE = 'peptide-news/v1'; const API_NAMESPACE = 'peptide-news/v1';
/** /**
* Register REST routes. * Register REST routes.
*/ */
public function register_routes() { public function register_routes() {
// Public: list articles. // Public: list articles.
register_rest_route( self::API_NAMESPACE, '/articles', array( register_rest_route( self::API_NAMESPACE, '/articles', array(
'methods' => 'GET', 'methods' => 'GET',
'callback' => array( $this, 'get_articles' ), 'callback' => array( $this, 'get_articles' ),
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
'args' => array( 'args' => array(
'count' => array( 'count' => array(
'default' => 10, 'default' => 10,
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
), ),
'page' => array( 'page' => array(
'default' => 1, 'default' => 1,
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
), ),
), ),
) ); ) );
// Admin-only analytics endpoints. // Admin-only analytics endpoints.
$analytics_routes = array( $analytics_routes = array(
'/analytics/top' => 'get_top_articles', '/analytics/top' => 'get_top_articles',
'/analytics/trends' => 'get_trends', '/analytics/trends' => 'get_trends',
'/analytics/topics' => 'get_topics', '/analytics/topics' => 'get_topics',
'/analytics/devices' => 'get_devices', '/analytics/devices' => 'get_devices',
'/analytics/sources' => 'get_sources', '/analytics/sources' => 'get_sources',
'/analytics/export' => 'get_export', '/analytics/export' => 'get_export',
); );
foreach ( $analytics_routes as $route => $callback ) { foreach ( $analytics_routes as $route => $callback ) {
register_rest_route( self::API_NAMESPACE, $route, array( register_rest_route( self::API_NAMESPACE, $route, array(
'methods' => 'GET', 'methods' => 'GET',
'callback' => array( $this, $callback ), 'callback' => array( $this, $callback ),
'permission_callback' => array( $this, 'check_admin_permissions' ), 'permission_callback' => array( $this, 'check_admin_permissions' ),
'args' => $this->get_date_range_args(), 'args' => $this->get_date_range_args(),
) ); ) );
} }
} }
/** /**
* Verify the request comes from an admin user. * Verify the request comes from an admin user.
* *
* @param WP_REST_Request $request * @param WP_REST_Request $request
* @return bool|WP_Error * @return bool|WP_Error
*/ */
public function check_admin_permissions( $request ) { public function check_admin_permissions( $request ) {
if ( ! current_user_can( 'manage_options' ) ) { if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error( return new WP_Error(
'rest_forbidden', 'rest_forbidden',
__( 'You do not have permission to access analytics data.', 'peptide-news' ), __( 'You do not have permission to access analytics data.', 'peptide-news' ),
array( 'status' => 403 ) array( 'status' => 403 )
); );
} }
return true; return true;
} }
/** /**
* Common date-range argument definitions. * Common date-range argument definitions.
* *
* @return array * @return array
*/ */
private function get_date_range_args() { private function get_date_range_args() {
return array( return array(
'start_date' => array( 'start_date' => array(
'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), 'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => array( $this, 'validate_date_format' ), 'validate_callback' => array( $this, 'validate_date_format' ),
), ),
'end_date' => array( 'end_date' => array(
'default' => gmdate( 'Y-m-d' ), 'default' => gmdate( 'Y-m-d' ),
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => array( $this, 'validate_date_format' ), 'validate_callback' => array( $this, 'validate_date_format' ),
), ),
'limit' => array( 'limit' => array(
'default' => 20, 'default' => 20,
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
), ),
); );
} }
/** /**
* Validate that a parameter is a Y-m-d date string. * Validate that a parameter is a Y-m-d date string.
* *
* @param string $value * @param string $value
* @param WP_REST_Request $request * @param WP_REST_Request $request
* @param string $param * @param string $param
* @return true|WP_Error * @return true|WP_Error
*/ */
public function validate_date_format( $value, $request, $param ) { public function validate_date_format( $value, $request, $param ) {
if ( ! preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $value ) ) { if ( ! preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $value ) ) {
return new WP_Error( return new WP_Error(
'rest_invalid_date', 'rest_invalid_date',
sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ), /* translators: %s: parameter name */
array( 'status' => 400 ) sprintf( __( 'Invalid date format for %s. Expected Y-m-d.', 'peptide-news' ), $param ),
); array( 'status' => 400 )
} );
return true; }
} return true;
}
/** /**
* GET /articles * GET /articles
* *
* Returns active articles with server-side transient caching. * Returns active articles with server-side transient caching.
* Cache is invalidated whenever new articles are stored. * Cache is invalidated whenever new articles are stored.
*/ */
public function get_articles( $request ) { public function get_articles( $request ) {
global $wpdb; global $wpdb;
$count = absint( min( $request->get_param( 'count' ), 100 ) ); $count = absint( min( $request->get_param( 'count' ), 100 ) );
$page = absint( max( $request->get_param( 'page' ), 1 ) ); $page = absint( max( $request->get_param( 'page' ), 1 ) );
$table = $wpdb->prefix . 'peptide_news_articles'; $table = $wpdb->prefix . 'peptide_news_articles';
// Build cache key from page and count params. // Build cache key from page and count params.
$cache_key = 'peptide_news_articles_' . $page . '_' . $count; $cache_key = 'peptide_news_articles_' . $page . '_' . $count;
$cached_response = get_transient( $cache_key ); $cached_response = get_transient( $cache_key );
if ( false !== $cached_response ) { if ( false !== $cached_response ) {
return rest_ensure_response( $cached_response ); return rest_ensure_response( $cached_response );
} }
$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) ); $total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) );
// Ensure page doesn't exceed max available pages. // Ensure page doesn't exceed max available pages.
$max_page = max( 1, (int) ceil( $total / $count ) ); $max_page = max( 1, (int) ceil( $total / $count ) );
$page = min( $page, $max_page ); $page = min( $page, $max_page );
$offset = ( $page - 1 ) * $count; $offset = ( $page - 1 ) * $count;
$articles = $wpdb->get_results( $wpdb->prepare( $articles = $wpdb->get_results( $wpdb->prepare(
"SELECT id, source, source_url, title, excerpt, ai_summary, author, "SELECT id, source, source_url, title, excerpt, ai_summary, author,
thumbnail_url, thumbnail_local, published_at, categories, tags thumbnail_url, thumbnail_local, published_at, categories, tags
FROM {$table} FROM {$table}
WHERE is_active = 1 WHERE is_active = 1
ORDER BY published_at DESC ORDER BY published_at DESC
LIMIT %d OFFSET %d", LIMIT %d OFFSET %d",
$count, $count,
$offset $offset
) ); ) );
// Strip source name from titles, excerpts, and summaries. // Strip source name from titles, excerpts, and summaries.
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space. $nbsp = "\xC2\xA0"; // UTF-8 non-breaking space.
foreach ( $articles as $article ) { foreach ( $articles as $article ) {
$fields = array( 'title', 'excerpt', 'ai_summary' ); $fields = array( 'title', 'excerpt', 'ai_summary' );
foreach ( $fields as $field ) { foreach ( $fields as $field ) {
if ( empty( $article->$field ) ) { if ( empty( $article->$field ) ) {
continue; continue;
} }
// First pass: use strip_source_suffix for known patterns. // First pass: use strip_source_suffix for known patterns.
$article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url ); $article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url );
// Second pass: catch any remaining nbsp-separated publisher names // Second pass: catch any remaining nbsp-separated publisher names
// (e.g., "...text\xC2\xA0\xC2\xA0Publisher Name" from Google News RSS). // (e.g., "...text\xC2\xA0\xC2\xA0Publisher Name" from Google News RSS).
$text = $article->$field; $text = $article->$field;
$pos = strrpos( $text, $nbsp . $nbsp ); $pos = strrpos( $text, $nbsp . $nbsp );
if ( false === $pos ) { if ( false === $pos ) {
$pos = strrpos( $text, $nbsp ); $pos = strrpos( $text, $nbsp );
} }
if ( false !== $pos && $pos > 10 ) { if ( false !== $pos && $pos > 10 ) {
$candidate = rtrim( substr( $text, 0, $pos ) ); $candidate = rtrim( substr( $text, 0, $pos ) );
if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) { if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) {
$article->$field = $candidate; $article->$field = $candidate;
} }
} }
} }
}
} $data = array(
'articles' => $articles,
'total' => (int) $total,
'page' => $page,
'per_page' => $count,
'total_pages' => ceil( $total / $count ),
);
$data = array( // Cache the response for 1 hour.
'articles' => $articles, set_transient( $cache_key, $data, HOUR_IN_SECONDS );
'total' => (int) $total,
'page' => $page,
'per_page' => $count,
'total_pages' => ceil( $total / $count ),
);
// Cache the response for 1 hour. $response = rest_ensure_response( $data );
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
$response = rest_ensure_response( $data ); return $response;
}
return $response; /**
} * Strip the source/publisher name from the end of a string.
*
* Handles common separator patterns like " - Source", " | Source",
* " — Source", and " – Source". Uses case-insensitive matching against
* the source field. Also extracts publisher names from source URLs
* and has a regex fallback for common RSS title patterns.
*
* @param string $text The text to clean.
* @param string $source The source name to strip.
* @param string $source_url The source URL (used to extract domain-based publisher names).
* @return string Cleaned text.
*/
private function strip_source_suffix( $text, $source, $source_url = '' ) {
if ( empty( $text ) ) {
return $text;
}
/** // Decode HTML entities first so &nbsp; becomes actual characters.
* Strip the source/publisher name from the end of a string. $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
*
* Handles common separator patterns like " - Source", " | Source",
* " — Source", and " – Source". Uses case-insensitive matching against
* the source field. Also extracts publisher names from source URLs
* and has a regex fallback for common RSS title patterns.
*
* @param string $text The text to clean.
* @param string $source The source name to strip.
* @param string $source_url The source URL (used to extract domain-based publisher names).
* @return string Cleaned text.
*/
private function strip_source_suffix( $text, $source, $source_url = '' ) {
if ( empty( $text ) ) {
return $text;
}
// Decode HTML entities first so &nbsp; becomes actual characters. // Common separators between title/excerpt and source name.
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); // Includes regular spaces, non-breaking spaces (\xC2\xA0), and typographic dashes.
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space
$separators = array(
' - ', ' | ', ' — ', ' – ', ' // ',
$nbsp . $nbsp, // double non-breaking space (Google News RSS excerpts)
' ', // double regular space
$nbsp, // single non-breaking space
);
// Common separators between title/excerpt and source name. // Build list of source names to try matching against.
// Includes regular spaces, non-breaking spaces (\xC2\xA0), and typographic dashes. $source_names = array();
$nbsp = "\xC2\xA0"; // UTF-8 non-breaking space if ( ! empty( $source ) ) {
$separators = array( $source_names[] = $source;
' - ', ' | ', ' — ', ' – ', ' // ', }
$nbsp . $nbsp, // double non-breaking space (Google News RSS excerpts)
' ', // double regular space
$nbsp, // single non-breaking space
);
// Build list of source names to try matching against. // Get cached source names list to avoid database queries on every API call.
$source_names = array(); $cached_sources = get_transient( 'peptide_news_source_names' );
if ( ! empty( $source ) ) { if ( false === $cached_sources ) {
$source_names[] = $source; global $wpdb;
} $table = $wpdb->prefix . 'peptide_news_articles';
$cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" );
if ( ! is_array( $cached_sources ) ) {
$cached_sources = array();
}
set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS );
}
$source_names = array_merge( $source_names, $cached_sources );
// Get cached source names list to avoid database queries on every API call. // Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net").
$cached_sources = get_transient( 'peptide_news_source_names' ); if ( ! empty( $source_url ) ) {
if ( false === $cached_sources ) { $host = wp_parse_url( $source_url, PHP_URL_HOST );
global $wpdb; if ( $host ) {
$table = $wpdb->prefix . 'peptide_news_articles'; $host = preg_replace( '/^www\./', '', $host );
$cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" ); $parts = explode( '.', $host );
if ( ! is_array( $cached_sources ) ) { if ( count( $parts ) >= 2 ) {
$cached_sources = array(); $domain_name = $parts[ count( $parts ) - 2 ];
} $source_names[] = str_replace( '-', ' ', $domain_name );
set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS ); $source_names[] = $domain_name;
} }
$source_names = array_merge( $source_names, $cached_sources ); }
}
// Extract domain-based publisher name (e.g., "News-Medical" from "news-medical.net"). // Try exact (case-insensitive) match against known source names.
if ( ! empty( $source_url ) ) { foreach ( $source_names as $name ) {
$host = wp_parse_url( $source_url, PHP_URL_HOST ); foreach ( $separators as $sep ) {
if ( $host ) { $suffix = $sep . $name;
$host = preg_replace( '/^www\./', '', $host ); $suffix_len = strlen( $suffix );
$parts = explode( '.', $host ); if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) {
if ( count( $parts ) >= 2 ) { return rtrim( substr( $text, 0, -$suffix_len ) );
$domain_name = $parts[ count( $parts ) - 2 ]; }
$source_names[] = str_replace( '-', ' ', $domain_name ); }
$source_names[] = $domain_name; }
}
}
}
// Try exact (case-insensitive) match against known source names. // Fallback 1: strip trailing " - Short Publisher Name" patterns (1-5 words).
foreach ( $source_names as $name ) { // Only strips if the part after the separator looks like a publisher name
foreach ( $separators as $sep ) { // (starts with uppercase, no sentence-ending punctuation).
$suffix = $sep . $name; $pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u';
$suffix_len = strlen( $suffix ); $candidate = preg_replace( $pattern, '', $text );
if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) { if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) {
return rtrim( substr( $text, 0, -$suffix_len ) ); return rtrim( $candidate );
} }
}
}
// Fallback 1: strip trailing " - Short Publisher Name" patterns (1-5 words). // Fallback 2: strip trailing nbsp-separated publisher names.
// Only strips if the part after the separator looks like a publisher name // Google News RSS excerpts use double non-breaking space + publisher name
// (starts with uppercase, no sentence-ending punctuation). // when the source field doesn't match (e.g., source = "news.google.com").
$pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u'; // Uses mb_ereg for reliable multibyte matching.
$candidate = preg_replace( $pattern, '', $text ); if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) {
if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) { if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) {
return rtrim( $candidate ); return rtrim( $m[1] );
} }
}
// Fallback 2: strip trailing nbsp-separated publisher names. return $text;
// Google News RSS excerpts use double non-breaking space + publisher name }
// when the source field doesn't match (e.g., source = "news.google.com").
// Uses mb_ereg for reliable multibyte matching.
if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) {
if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) {
return rtrim( $m[1] );
}
}
return $text; /**
} * GET /analytics/top
*/
public function get_top_articles( $request ) {
$data = Peptide_News_Analytics::get_top_articles(
$request->get_param( 'start_date' ),
$request->get_param( 'end_date' ),
$request->get_param( 'limit' )
);
return rest_ensure_response( $data );
}
/** /**
* GET /analytics/top * GET /analytics/trends
*/ */
public function get_top_articles( $request ) { public function get_trends( $request ) {
$data = Peptide_News_Analytics::get_top_articles( $data = Peptide_News_Analytics::get_click_trends(
$request->get_param( 'start_date' ), $request->get_param( 'start_date' ),
$request->get_param( 'end_date' ), $request->get_param( 'end_date' )
$request->get_param( 'limit' ) );
); return rest_ensure_response( $data );
return rest_ensure_response( $data ); }
}
/** /**
* GET /analytics/trends * GET /analytics/topics
*/ */
public function get_trends( $request ) { public function get_topics( $request ) {
$data = Peptide_News_Analytics::get_click_trends( $data = Peptide_News_Analytics::get_popular_topics(
$request->get_param( 'start_date' ), $request->get_param( 'start_date' ),
$request->get_param( 'end_date' ) $request->get_param( 'end_date' ),
); $request->get_param( 'limit' )
return rest_ensure_response( $data ); );
} return rest_ensure_response( $data );
}
/** /**
* GET /analytics/topics * GET /analytics/devices
*/ */
public function get_topics( $request ) { public function get_devices( $request ) {
$data = Peptide_News_Analytics::get_popular_topics( $data = Peptide_News_Analytics::get_device_breakdown(
$request->get_param( 'start_date' ), $request->get_param( 'start_date' ),
$request->get_param( 'end_date' ), $request->get_param( 'end_date' )
$request->get_param( 'limit' ) );
); return rest_ensure_response( $data );
return rest_ensure_response( $data ); }
}
/** /**
* GET /analytics/devices * GET /analytics/sources
*/ */
public function get_devices( $request ) { public function get_sources( $request ) {
$data = Peptide_News_Analytics::get_device_breakdown( $data = Peptide_News_Analytics::get_source_performance(
$request->get_param( 'start_date' ), $request->get_param( 'start_date' ),
$request->get_param( 'end_date' ) $request->get_param( 'end_date' )
); );
return rest_ensure_response( $data ); return rest_ensure_response( $data );
} }
/** /**
* GET /analytics/sources * GET /analytics/export — returns CSV download.
*/ */
public function get_sources( $request ) { public function get_export( $request ) {
$data = Peptide_News_Analytics::get_source_performance( $data = Peptide_News_Analytics::export_clicks_csv(
$request->get_param( 'start_date' ), $request->get_param( 'start_date' ),
$request->get_param( 'end_date' ) $request->get_param( 'end_date' )
); );
return rest_ensure_response( $data );
}
/** if ( empty( $data ) ) {
* GET /analytics/export — returns CSV download. return rest_ensure_response( array( 'message' => 'No data for the selected period.' ) );
*/ }
public function get_export( $request ) {
$data = Peptide_News_Analytics::export_clicks_csv(
$request->get_param( 'start_date' ),
$request->get_param( 'end_date' )
);
if ( empty( $data ) ) { // Return as JSON (client can convert to CSV).
return rest_ensure_response( array( 'message' => 'No data for the selected period.' ) ); return rest_ensure_response( array(
} 'headers' => array_keys( $data[0] ),
'rows' => $data,
'count' => count( $data ),
) );
}
// Return as JSON (client can convert to CSV). /**
return rest_ensure_response( array( * AJAX handler: delete one or more articles by ID.
'headers' => array_keys( $data[0] ), *
'rows' => $data, * Expects POST with 'ids' (comma-separated article IDs) and 'nonce'.
'count' => count( $data ), * Cascading foreign keys on the clicks and daily_stats tables handle
) ); * related analytics cleanup automatically.
} *
* @since 2.1.0
*/
public static function ajax_delete_articles() {
check_ajax_referer( 'peptide_news_admin', 'nonce' );
/** if ( ! current_user_can( 'manage_options' ) ) {
* AJAX handler: delete one or more articles by ID. wp_send_json_error( 'Unauthorized.', 403 );
* }
* Expects POST with 'ids' (comma-separated article IDs) and 'nonce'.
* Cascading foreign keys on the clicks and daily_stats tables handle
* related analytics cleanup automatically.
*
* @since 2.1.0
*/
public static function ajax_delete_articles() {
check_ajax_referer( 'peptide_news_admin', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) { $raw_ids = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : '';
wp_send_json_error( 'Unauthorized.', 403 ); if ( empty( $raw_ids ) ) {
} wp_send_json_error( 'No article IDs provided.' );
}
$raw_ids = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : ''; $ids = array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) );
if ( empty( $raw_ids ) ) { if ( empty( $ids ) ) {
wp_send_json_error( 'No article IDs provided.' ); wp_send_json_error( 'Invalid article IDs.' );
} }
$ids = array_filter( array_map( 'absint', explode( ',', $raw_ids ) ) ); global $wpdb;
if ( empty( $ids ) ) { $table = $wpdb->prefix . 'peptide_news_articles';
wp_send_json_error( 'Invalid article IDs.' ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
}
global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$table = $wpdb->prefix . 'peptide_news_articles'; $deleted = $wpdb->query(
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"DELETE FROM {$table} WHERE id IN ({$placeholders})",
$ids
)
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( false === $deleted ) {
$deleted = $wpdb->query( wp_send_json_error( 'Database error.' );
$wpdb->prepare( }
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"DELETE FROM {$table} WHERE id IN ({$placeholders})",
$ids
)
);
if ( false === $deleted ) { // Clear article cache so the frontend reflects changes.
wp_send_json_error( 'Database error.' ); delete_transient( 'peptide_news_articles_1_10' );
}
// Clear article cache so the frontend reflects changes. Peptide_News_Logger::info( 'Deleted ' . $deleted . ' article(s): IDs ' . implode( ', ', $ids ), 'admin' );
delete_transient( 'peptide_news_articles_1_10' );
Peptide_News_Logger::info( 'Deleted ' . $deleted . ' article(s): IDs ' . implode( ', ', $ids ), 'admin' ); wp_send_json_success( array(
'deleted' => $deleted,
wp_send_json_success( array( 'ids' => $ids,
'deleted' => $deleted, ) );
'ids' => $ids, }
) );
}
} }

View File

@@ -8,111 +8,111 @@
*/ */
class Peptide_News { class Peptide_News {
/** @var Peptide_News_Loader */ /** @var Peptide_News_Loader */
protected $loader; protected $loader;
/** @var string */ /** @var string */
protected $plugin_name; protected $plugin_name;
/** @var string */ /** @var string */
protected $version; protected $version;
public function __construct() { public function __construct() {
$this->version = PEPTIDE_NEWS_VERSION; $this->version = PEPTIDE_NEWS_VERSION;
$this->plugin_name = 'peptide-news'; $this->plugin_name = 'peptide-news';
$this->load_dependencies(); $this->load_dependencies();
$this->define_admin_hooks(); $this->define_admin_hooks();
$this->define_public_hooks(); $this->define_public_hooks();
$this->define_cron_hooks(); $this->define_cron_hooks();
$this->define_rest_hooks(); $this->define_rest_hooks();
} }
/** /**
* Load all required files. * Load all required files.
*/ */
private function load_dependencies() { private function load_dependencies() {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-peptide-news-admin.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'public/class-peptide-news-public.php';
$this->loader = new Peptide_News_Loader(); $this->loader = new Peptide_News_Loader();
} }
/** /**
* Register admin-side hooks. * Register admin-side hooks.
*/ */
private function define_admin_hooks() { private function define_admin_hooks() {
$admin = new Peptide_News_Admin( $this->plugin_name, $this->version ); $admin = new Peptide_News_Admin( $this->plugin_name, $this->version );
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' ); $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' );
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' ); $this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' );
$this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' ); $this->loader->add_action( 'admin_menu', $admin, 'add_admin_menu' );
$this->loader->add_action( 'admin_init', $admin, 'register_settings' ); $this->loader->add_action( 'admin_init', $admin, 'register_settings' );
} }
/** /**
* Register public-facing hooks. * Register public-facing hooks.
*/ */
private function define_public_hooks() { private function define_public_hooks() {
$public = new Peptide_News_Public( $this->plugin_name, $this->version ); $public = new Peptide_News_Public( $this->plugin_name, $this->version );
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' ); $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' );
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' ); $this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' );
$this->loader->add_action( 'init', $public, 'register_shortcode' ); $this->loader->add_action( 'init', $public, 'register_shortcode' );
$this->loader->add_action( 'widgets_init', $public, 'register_widget' ); $this->loader->add_action( 'widgets_init', $public, 'register_widget' );
$this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' ); $this->loader->add_action( 'wp_ajax_peptide_news_track_click', $public, 'handle_click_tracking' );
$this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' ); $this->loader->add_action( 'wp_ajax_nopriv_peptide_news_track_click', $public, 'handle_click_tracking' );
} }
/** /**
* Register WP-Cron hooks for scheduled fetching. * Register WP-Cron hooks for scheduled fetching.
*/ */
private function define_cron_hooks() { private function define_cron_hooks() {
$fetcher = new Peptide_News_Fetcher(); $fetcher = new Peptide_News_Fetcher();
$this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' ); $this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
$this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' ); $this->loader->add_filter( 'cron_schedules', $fetcher, 'add_custom_cron_schedules' );
$this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' ); $this->loader->add_action( 'wp_ajax_peptide_news_backfill_sources', $fetcher, 'ajax_backfill_sources' );
$this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' ); $this->loader->add_action( 'wp_ajax_peptide_news_generate_summaries', 'Peptide_News_LLM', 'ajax_generate_summaries' );
$this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' ); $this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' );
$this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' ); $this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' );
$this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' ); $this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' );
} }
/** /**
* Register REST API hooks. * Register REST API hooks.
*/ */
private function define_rest_hooks() { private function define_rest_hooks() {
$rest = new Peptide_News_Rest_API(); $rest = new Peptide_News_Rest_API();
$this->loader->add_action( 'rest_api_init', $rest, 'register_routes' ); $this->loader->add_action( 'rest_api_init', $rest, 'register_routes' );
} }
/** /**
* Run the loader to execute all registered hooks. * Run the loader to execute all registered hooks.
*/ */
public function run() { public function run() {
$this->loader->run(); $this->loader->run();
} }
public function get_plugin_name() { public function get_plugin_name() {
return $this->plugin_name; return $this->plugin_name;
} }
public function get_version() { public function get_version() {
return $this->version; return $this->version;
} }
public function get_loader() { public function get_loader() {
return $this->loader; return $this->loader;
} }
} }

View File

@@ -16,7 +16,7 @@
// Abort if called directly. // Abort if called directly.
if ( ! defined( 'WPINC' ) ) { if ( ! defined( 'WPINC' ) ) {
die; die;
} }
/** /**
@@ -31,16 +31,16 @@ define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
* Runs during plugin activation. * Runs during plugin activation.
*/ */
function peptide_news_activate() { function peptide_news_activate() {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
Peptide_News_Activator::activate(); Peptide_News_Activator::activate();
} }
/** /**
* Runs during plugin deactivation. * Runs during plugin deactivation.
*/ */
function peptide_news_deactivate() { function peptide_news_deactivate() {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
Peptide_News_Deactivator::deactivate(); Peptide_News_Deactivator::deactivate();
} }
register_activation_hook( __FILE__, 'peptide_news_activate' ); register_activation_hook( __FILE__, 'peptide_news_activate' );
@@ -57,12 +57,12 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news.php';
* without re-activating the plugin. * without re-activating the plugin.
*/ */
function peptide_news_check_db_upgrade() { function peptide_news_check_db_upgrade() {
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' ); $installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) { if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
Peptide_News_Activator::activate(); Peptide_News_Activator::activate();
} }
} }
add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' ); add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' );
@@ -70,8 +70,8 @@ add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' );
* Begins execution of the plugin. * Begins execution of the plugin.
*/ */
function peptide_news_run() { function peptide_news_run() {
$plugin = new Peptide_News(); $plugin = new Peptide_News();
$plugin->run(); $plugin->run();
} }
peptide_news_run(); peptide_news_run();

View File

@@ -10,161 +10,161 @@
*/ */
class Peptide_News_Public { class Peptide_News_Public {
/** @var string */ /** @var string */
private $plugin_name; private $plugin_name;
/** @var string */ /** @var string */
private $version; private $version;
public function __construct( $plugin_name, $version ) { public function __construct( $plugin_name, $version ) {
$this->plugin_name = $plugin_name; $this->plugin_name = $plugin_name;
$this->version = $version; $this->version = $version;
} }
/** /**
* Register front-end CSS (enqueued on demand by the shortcode/widget). * Register front-end CSS (enqueued on demand by the shortcode/widget).
*/ */
public function enqueue_styles() { public function enqueue_styles() {
wp_register_style( wp_register_style(
$this->plugin_name, $this->plugin_name,
PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css', PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css',
array(), array(),
$this->version, $this->version,
'all' 'all'
); );
} }
/** /**
* Register front-end JS (enqueued on demand by the shortcode/widget). * Register front-end JS (enqueued on demand by the shortcode/widget).
* *
* Loads React (from WordPress core), the feed component, and * Loads React (from WordPress core), the feed component, and
* passes configuration via wp_localize_script. * passes configuration via wp_localize_script.
*/ */
public function enqueue_scripts() { public function enqueue_scripts() {
// React feed component (depends on WP-bundled React). // React feed component (depends on WP-bundled React).
wp_register_script( wp_register_script(
$this->plugin_name . '-feed', $this->plugin_name . '-feed',
PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js', PEPTIDE_NEWS_PLUGIN_URL . 'public/js/peptide-news-feed.js',
array( 'react', 'react-dom' ), array( 'react', 'react-dom' ),
$this->version, $this->version,
true true
); );
} }
/** /**
* Register the shortcode. * Register the shortcode.
*/ */
public function register_shortcode() { public function register_shortcode() {
add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) ); add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) );
} }
/** /**
* Register the widget. * Register the widget.
*/ */
public function register_widget() { public function register_widget() {
register_widget( 'Peptide_News_Widget' ); register_widget( 'Peptide_News_Widget' );
} }
/** /**
* Render the [peptide_news] shortcode. * Render the [peptide_news] shortcode.
* *
* Outputs a mount-point div for the React feed component and * Outputs a mount-point div for the React feed component and
* enqueues all required assets. Configuration is passed to JS * enqueues all required assets. Configuration is passed to JS
* via a localized script object. * via a localized script object.
* *
* @param array $atts Shortcode attributes. * @param array $atts Shortcode attributes.
* @return string HTML output. * @return string HTML output.
*/ */
public function render_shortcode( $atts ) { public function render_shortcode( $atts ) {
$atts = shortcode_atts( array( $atts = shortcode_atts( array(
'count' => get_option( 'peptide_news_articles_count', 10 ), 'count' => get_option( 'peptide_news_articles_count', 10 ),
), $atts, 'peptide_news' ); ), $atts, 'peptide_news' );
$count = absint( $atts['count'] ); $count = absint( $atts['count'] );
// Enqueue assets only when the shortcode is actually rendered. // Enqueue assets only when the shortcode is actually rendered.
wp_enqueue_style( $this->plugin_name ); wp_enqueue_style( $this->plugin_name );
wp_enqueue_script( $this->plugin_name . '-feed' ); wp_enqueue_script( $this->plugin_name . '-feed' );
// Pass configuration to the React component. // Pass configuration to the React component.
wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array( wp_localize_script( $this->plugin_name . '-feed', 'peptideNewsFeed', array(
'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ), 'restUrl' => esc_url_raw( rest_url( 'peptide-news/v1/' ) ),
'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'peptide_news_track' ), 'nonce' => wp_create_nonce( 'peptide_news_track' ),
'sessionId' => $this->get_or_create_session_id(), 'sessionId' => $this->get_or_create_session_id(),
'count' => $count, 'count' => $count,
) ); ) );
// React mount point — the component renders into this div. // React mount point — the component renders into this div.
return '<div data-peptide-news-feed></div>'; return '<div data-peptide-news-feed></div>';
} }
/** /**
* Handle AJAX click tracking with basic rate limiting. * Handle AJAX click tracking with basic rate limiting.
*/ */
public function handle_click_tracking() { public function handle_click_tracking() {
check_ajax_referer( 'peptide_news_track', 'nonce' ); check_ajax_referer( 'peptide_news_track', 'nonce' );
$article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0; $article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0;
if ( ! $article_id ) { if ( ! $article_id ) {
wp_send_json_error( 'Invalid article ID.' ); wp_send_json_error( 'Invalid article ID.' );
} }
// Rate limit: max 1 click per article per session per 5 seconds. // Rate limit: max 1 click per article per session per 5 seconds.
$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : ''; $session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
$throttle_key = 'pn_click_' . md5( $session_id . '_' . $article_id ); $throttle_key = 'pn_click_' . md5( $session_id . '_' . $article_id );
if ( get_transient( $throttle_key ) ) { if ( get_transient( $throttle_key ) ) {
wp_send_json_success( array( 'throttled' => true ) ); wp_send_json_success( array( 'throttled' => true ) );
} }
set_transient( $throttle_key, 1, 5 ); set_transient( $throttle_key, 1, 5 );
$meta = array( $meta = array(
'ip' => '', 'ip' => '',
'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
'referrer' => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( $_POST['referrer'] ) ) : '', 'referrer' => isset( $_POST['referrer'] ) ? esc_url_raw( wp_unslash( $_POST['referrer'] ) ) : '',
'page_url' => isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '', 'page_url' => isset( $_POST['page_url'] ) ? esc_url_raw( wp_unslash( $_POST['page_url'] ) ) : '',
'session_id' => $session_id, 'session_id' => $session_id,
); );
$result = Peptide_News_Analytics::record_click( $article_id, $meta ); $result = Peptide_News_Analytics::record_click( $article_id, $meta );
if ( $result ) { if ( $result ) {
wp_send_json_success(); wp_send_json_success();
} else { } else {
wp_send_json_error( 'Failed to record click.' ); wp_send_json_error( 'Failed to record click.' );
} }
} }
/** /**
* Generate or retrieve a session ID for visitor tracking. * Generate or retrieve a session ID for visitor tracking.
* *
* @return string * @return string
*/ */
private function get_or_create_session_id() { private function get_or_create_session_id() {
if ( isset( $_COOKIE['pn_session_id'] ) ) { if ( isset( $_COOKIE['pn_session_id'] ) ) {
return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) ); return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) );
} }
$session_id = wp_generate_uuid4(); $session_id = wp_generate_uuid4();
if ( ! headers_sent() ) { if ( ! headers_sent() ) {
setcookie( setcookie(
'pn_session_id', 'pn_session_id',
$session_id, $session_id,
array( array(
'expires' => time() + 1800, 'expires' => time() + 1800,
'path' => '/', 'path' => '/',
'secure' => is_ssl(), 'secure' => is_ssl(),
'httponly' => true, 'httponly' => true,
'samesite' => 'Lax', 'samesite' => 'Lax',
) )
); );
} }
return $session_id; return $session_id;
} }
} }
/** /**
@@ -174,63 +174,63 @@ class Peptide_News_Public {
*/ */
class Peptide_News_Widget extends WP_Widget { class Peptide_News_Widget extends WP_Widget {
public function __construct() { public function __construct() {
parent::__construct( parent::__construct(
'peptide_news_widget', 'peptide_news_widget',
__( 'Peptide News', 'peptide-news' ), __( 'Peptide News', 'peptide-news' ),
array( array(
'description' => __( 'Displays the latest peptide research news.', 'peptide-news' ), 'description' => __( 'Displays the latest peptide research news.', 'peptide-news' ),
'classname' => 'peptide-news-widget', 'classname' => 'peptide-news-widget',
) )
); );
} }
public function widget( $args, $instance ) { public function widget( $args, $instance ) {
$title = apply_filters( 'widget_title', $instance['title'] ?? __( 'Peptide News', 'peptide-news' ) ); $title = apply_filters( 'widget_title', $instance['title'] ?? __( 'Peptide News', 'peptide-news' ) );
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5; $count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
echo $args['before_widget']; echo $args['before_widget'];
if ( $title ) { if ( $title ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title']; echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
} }
$public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION ); $public = new Peptide_News_Public( 'peptide-news', PEPTIDE_NEWS_VERSION );
echo $public->render_shortcode( array( 'count' => $count ) ); echo $public->render_shortcode( array( 'count' => $count ) );
echo $args['after_widget']; echo $args['after_widget'];
} }
public function form( $instance ) { public function form( $instance ) {
if ( ! is_array( $instance ) ) { if ( ! is_array( $instance ) ) {
$instance = array(); $instance = array();
} }
$title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' ); $title = isset( $instance['title'] ) ? $instance['title'] : __( 'Peptide News', 'peptide-news' );
$count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5; $count = isset( $instance['count'] ) ? absint( $instance['count'] ) : 5;
?> ?>
<p> <p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"> <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
<?php esc_html_e( 'Title:', 'peptide-news' ); ?> <?php esc_html_e( 'Title:', 'peptide-news' ); ?>
</label> </label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" <input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" value="<?php echo esc_attr( $title ); ?>" /> type="text" value="<?php echo esc_attr( $title ); ?>" />
</p> </p>
<p> <p>
<label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"> <label for="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>">
<?php esc_html_e( 'Number of articles:', 'peptide-news' ); ?> <?php esc_html_e( 'Number of articles:', 'peptide-news' ); ?>
</label> </label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>" <input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'count' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'count' ) ); ?>"
type="number" min="1" max="50" value="<?php echo esc_attr( $count ); ?>" /> type="number" min="1" max="50" value="<?php echo esc_attr( $count ); ?>" />
</p> </p>
<?php <?php
} }
public function update( $new_instance, $old_instance ) { public function update( $new_instance, $old_instance ) {
$instance = array(); $instance = array();
$instance['title'] = sanitize_text_field( $new_instance['title'] ?? '' ); $instance['title'] = sanitize_text_field( $new_instance['title'] ?? '' );
$instance['count'] = absint( $new_instance['count'] ?? 5 ); $instance['count'] = absint( $new_instance['count'] ?? 5 );
return $instance; return $instance;
} }
} }