Deploy b92dc4b from main
This commit is contained in:
171
admin/class-peptide-news-admin.php
Normal file → Executable file
171
admin/class-peptide-news-admin.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Admin-specific functionality.
|
||||
*
|
||||
@@ -14,7 +15,7 @@ class Peptide_News_Admin {
|
||||
/** @var string */
|
||||
private $version;
|
||||
|
||||
public function __construct( $plugin_name, $version ) {
|
||||
public function __construct( string $plugin_name, string $version ) {
|
||||
$this->plugin_name = $plugin_name;
|
||||
$this->version = $version;
|
||||
}
|
||||
@@ -22,7 +23,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Enqueue admin CSS.
|
||||
*/
|
||||
public function enqueue_styles( $hook ) {
|
||||
public function enqueue_styles( string $hook ): void {
|
||||
if ( strpos( $hook, 'peptide-news' ) === false ) {
|
||||
return;
|
||||
}
|
||||
@@ -38,7 +39,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Enqueue admin JS.
|
||||
*/
|
||||
public function enqueue_scripts( $hook ) {
|
||||
public function enqueue_scripts( string $hook ): void {
|
||||
if ( strpos( $hook, 'peptide-news' ) === false ) {
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +72,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Add admin menu pages.
|
||||
*/
|
||||
public function add_admin_menu() {
|
||||
public function add_admin_menu(): void {
|
||||
// Main menu — Analytics Dashboard.
|
||||
add_menu_page(
|
||||
__( 'Peptide News', 'peptide-news' ),
|
||||
@@ -102,12 +103,22 @@ class Peptide_News_Admin {
|
||||
'peptide-news-articles',
|
||||
array( $this, 'render_articles_page' )
|
||||
);
|
||||
|
||||
// Sub-menu — Cost Dashboard.
|
||||
add_submenu_page(
|
||||
'peptide-news-dashboard',
|
||||
__( 'LLM Costs', 'peptide-news' ),
|
||||
__( 'LLM Costs', 'peptide-news' ),
|
||||
'manage_options',
|
||||
'peptide-news-costs',
|
||||
array( $this, 'render_cost_dashboard_page' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all settings.
|
||||
*/
|
||||
public function register_settings() {
|
||||
public function register_settings(): void {
|
||||
|
||||
// --- General section ---
|
||||
add_settings_section(
|
||||
@@ -213,12 +224,43 @@ class Peptide_News_Admin {
|
||||
$this->add_setting( 'llm_summary_model', __( 'Summary Model', 'peptide-news' ), 'render_llm_summary_model_field', 'peptide_news_llm_section' );
|
||||
|
||||
$this->add_setting( 'llm_max_articles', __( 'Max Articles per Cycle', 'peptide-news' ), 'render_llm_max_articles_field', 'peptide_news_llm_section' );
|
||||
|
||||
// --- Cost Tracking / Budget section ---
|
||||
add_settings_section(
|
||||
'peptide_news_cost_section',
|
||||
__( 'Cost Tracking & Budget', 'peptide-news' ),
|
||||
function () {
|
||||
echo '<p>' . esc_html__( 'Monitor and control LLM API spending. Set a monthly budget to prevent unexpected charges.', 'peptide-news' ) . '</p>';
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) {
|
||||
$month_spend = Peptide_News_Cost_Tracker::get_current_month_spend();
|
||||
$budget = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
printf(
|
||||
'<p class="description"><strong>%s:</strong> $%.4f',
|
||||
esc_html__( 'This month\'s spend', 'peptide-news' ),
|
||||
$month_spend
|
||||
);
|
||||
if ( $budget > 0 ) {
|
||||
printf(
|
||||
' / $%.2f (%.1f%%)',
|
||||
$budget,
|
||||
( $month_spend / $budget ) * 100
|
||||
);
|
||||
}
|
||||
echo '</p>';
|
||||
}
|
||||
},
|
||||
'peptide-news-settings'
|
||||
);
|
||||
|
||||
$this->add_setting( 'monthly_budget', __( 'Monthly Budget (USD)', 'peptide-news' ), 'render_monthly_budget_field', 'peptide_news_cost_section' );
|
||||
$this->add_setting( 'budget_mode', __( 'Budget Enforcement', 'peptide-news' ), 'render_budget_mode_field', 'peptide_news_cost_section' );
|
||||
$this->add_setting( 'cost_retention', __( 'Cost Log Retention (days)', 'peptide-news' ), 'render_cost_retention_field', 'peptide_news_cost_section' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: register a single setting with a field callback.
|
||||
*/
|
||||
private function add_setting( $key, $label, $callback, $section ) {
|
||||
private function add_setting( string $key, string $label, string $callback, string $section ): void {
|
||||
$option_name = "peptide_news_{$key}";
|
||||
|
||||
$sanitize_cb = $this->get_sanitize_callback( $key );
|
||||
@@ -242,7 +284,7 @@ class Peptide_News_Admin {
|
||||
* @param string $key Setting key (without prefix).
|
||||
* @return callable
|
||||
*/
|
||||
private function get_sanitize_callback( $key ) {
|
||||
private function get_sanitize_callback( string $key ) {
|
||||
$callbacks = array(
|
||||
'fetch_interval' => 'sanitize_text_field',
|
||||
'articles_count' => 'absint',
|
||||
@@ -270,6 +312,16 @@ class Peptide_News_Admin {
|
||||
'llm_keywords_model' => array( $this, 'sanitize_model_id' ),
|
||||
'llm_summary_model' => array( $this, 'sanitize_model_id' ),
|
||||
'llm_max_articles' => 'absint',
|
||||
'monthly_budget' => function ( $value ) {
|
||||
return max( 0.0, (float) $value );
|
||||
},
|
||||
'budget_mode' => function ( $value ) {
|
||||
$valid = array( 'disabled', 'hard_stop', 'warn_only' );
|
||||
return in_array( $value, $valid, true ) ? $value : 'disabled';
|
||||
},
|
||||
'cost_retention' => function ( $value ) {
|
||||
return max( 30, absint( $value ) );
|
||||
},
|
||||
);
|
||||
|
||||
return isset( $callbacks[ $key ] ) ? $callbacks[ $key ] : 'sanitize_text_field';
|
||||
@@ -281,7 +333,7 @@ class Peptide_News_Admin {
|
||||
* @param string $value Raw input.
|
||||
* @return string Sanitized model ID or default.
|
||||
*/
|
||||
public function sanitize_model_id( $value ) {
|
||||
public function sanitize_model_id( string $value ): string {
|
||||
$value = sanitize_text_field( $value );
|
||||
if ( ! empty( $value ) && class_exists( 'Peptide_News_LLM' ) && ! Peptide_News_LLM::is_valid_model( $value ) ) {
|
||||
add_settings_error(
|
||||
@@ -300,13 +352,17 @@ class Peptide_News_Admin {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize API keys. If a masked key is submitted (••••••••XXXX format),
|
||||
* keep the existing key. Otherwise, store the new full key.
|
||||
* Sanitize and encrypt API keys before storage.
|
||||
*
|
||||
* @param string $value Raw input.
|
||||
* @return string Stored API key (full value or existing).
|
||||
* If a masked key is submitted (••••••••XXXX format), the existing
|
||||
* encrypted value is preserved. New plaintext keys are encrypted
|
||||
* via Peptide_News_Encryption before being written to wp_options.
|
||||
*
|
||||
* @param string $value Raw input from the settings form.
|
||||
* @param string $option_name The wp_options key for this API key.
|
||||
* @return string Encrypted API key or existing stored value.
|
||||
*/
|
||||
public function sanitize_api_key( $value, $option_name ) {
|
||||
public function sanitize_api_key( string $value, string $option_name ): string {
|
||||
$value = sanitize_text_field( $value );
|
||||
if ( empty( $value ) ) {
|
||||
return '';
|
||||
@@ -317,19 +373,33 @@ class Peptide_News_Admin {
|
||||
return get_option( $option_name, '' );
|
||||
}
|
||||
|
||||
// Encrypt the new plaintext key before storing.
|
||||
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
return Peptide_News_Encryption::encrypt( $value );
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a masked version of an API key showing only the last 4 characters.
|
||||
*
|
||||
* @param string $key Full API key.
|
||||
* Decrypts the stored value first (handles both encrypted and legacy
|
||||
* plaintext keys transparently).
|
||||
*
|
||||
* @param string $key Stored API key (encrypted or plaintext).
|
||||
* @return string Masked key (e.g., ••••••••XXXX).
|
||||
*/
|
||||
private function mask_api_key( $key ) {
|
||||
private function mask_api_key( string $key ): string {
|
||||
if ( empty( $key ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Decrypt before masking so we show the real key's last 4 chars.
|
||||
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
$key = Peptide_News_Encryption::decrypt( $key );
|
||||
}
|
||||
|
||||
$len = strlen( $key );
|
||||
if ( $len <= 4 ) {
|
||||
return str_repeat( '•', $len );
|
||||
@@ -591,9 +661,57 @@ class Peptide_News_Admin {
|
||||
echo '<p class="description">' . esc_html__( 'Maximum number of articles to analyze with AI per fetch cycle. Controls API costs and prevents cron timeouts.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
// --- Cost tracking field renderers ---
|
||||
|
||||
/**
|
||||
* Render the monthly budget input field.
|
||||
*/
|
||||
public function render_monthly_budget_field(): void {
|
||||
$value = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_monthly_budget" value="%.2f" min="0" step="0.01" class="small-text" style="width:100px;" />',
|
||||
$value
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'Set to 0 to disable budget limits. The plugin will stop making API calls when this amount is reached (if enforcement is enabled).', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the budget enforcement mode selector.
|
||||
*/
|
||||
public function render_budget_mode_field(): void {
|
||||
$value = get_option( 'peptide_news_budget_mode', 'disabled' );
|
||||
$modes = array(
|
||||
'disabled' => __( 'Disabled — track costs only, no limits', 'peptide-news' ),
|
||||
'warn_only' => __( 'Warn only — log alerts at 50%, 80%, 100%', 'peptide-news' ),
|
||||
'hard_stop' => __( 'Hard stop — block API calls when budget is reached', 'peptide-news' ),
|
||||
);
|
||||
echo '<select name="peptide_news_budget_mode">';
|
||||
foreach ( $modes as $mode => $label ) {
|
||||
printf(
|
||||
'<option value="%s" %s>%s</option>',
|
||||
esc_attr( $mode ),
|
||||
selected( $value, $mode, false ),
|
||||
esc_html( $label )
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the cost log retention field.
|
||||
*/
|
||||
public function render_cost_retention_field(): void {
|
||||
$value = absint( get_option( 'peptide_news_cost_retention', 365 ) );
|
||||
printf(
|
||||
'<input type="number" name="peptide_news_cost_retention" value="%d" min="30" max="3650" class="small-text" />',
|
||||
$value
|
||||
);
|
||||
echo '<p class="description">' . esc_html__( 'How long to keep detailed cost records. Minimum 30 days.', 'peptide-news' ) . '</p>';
|
||||
}
|
||||
|
||||
// --- Page renderers ---
|
||||
|
||||
public function render_settings_page() {
|
||||
public function render_settings_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
@@ -895,7 +1013,7 @@ class Peptide_News_Admin {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
public function render_dashboard_page() {
|
||||
public function render_dashboard_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
@@ -913,7 +1031,7 @@ class Peptide_News_Admin {
|
||||
/**
|
||||
* Stream a CSV export of click analytics data.
|
||||
*/
|
||||
private function export_csv() {
|
||||
private function export_csv(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Unauthorized.', 'peptide-news' ) );
|
||||
}
|
||||
@@ -947,10 +1065,25 @@ class Peptide_News_Admin {
|
||||
exit;
|
||||
}
|
||||
|
||||
public function render_articles_page() {
|
||||
public function render_articles_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/articles-list.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the LLM cost tracking dashboard page.
|
||||
*
|
||||
* Shows monthly budget status, daily cost chart, per-model breakdown,
|
||||
* and recent API call log. Data is loaded via AJAX for responsiveness.
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public function render_cost_dashboard_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
include PEPTIDE_NEWS_PLUGIN_DIR . 'admin/partials/cost-dashboard.php';
|
||||
}
|
||||
}
|
||||
|
||||
0
admin/css/admin-style.css
Normal file → Executable file
0
admin/css/admin-style.css
Normal file → Executable file
0
admin/js/admin-script.js
Normal file → Executable file
0
admin/js/admin-script.js
Normal file → Executable file
2
admin/partials/articles-list.php
Normal file → Executable file
2
admin/partials/articles-list.php
Normal file → Executable file
@@ -5,6 +5,8 @@
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
325
admin/partials/cost-dashboard.php
Executable file
325
admin/partials/cost-dashboard.php
Executable file
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
/**
|
||||
* Cost Dashboard admin partial.
|
||||
*
|
||||
* Displays LLM API cost tracking data: budget status, daily cost chart,
|
||||
* per-model and per-operation breakdowns, and a recent API call log.
|
||||
*
|
||||
* Data is loaded via AJAX (peptide_news_get_cost_data action) and rendered
|
||||
* client-side using Chart.js for the daily cost trend chart.
|
||||
*
|
||||
* @since 2.4.0
|
||||
* @see class-peptide-news-cost-tracker.php — Data source for all cost queries
|
||||
* @see class-peptide-news-admin.php — Registers this page via add_submenu_page()
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e( 'LLM Cost Dashboard', 'peptide-news' ); ?></h1>
|
||||
|
||||
<!-- Period selector -->
|
||||
<div style="margin: 15px 0;">
|
||||
<label for="pn-cost-period"><strong><?php esc_html_e( 'Period:', 'peptide-news' ); ?></strong></label>
|
||||
<select id="pn-cost-period" style="margin-left: 5px;">
|
||||
<option value="day"><?php esc_html_e( 'Today', 'peptide-news' ); ?></option>
|
||||
<option value="week"><?php esc_html_e( 'Last 7 Days', 'peptide-news' ); ?></option>
|
||||
<option value="month" selected><?php esc_html_e( 'This Month', 'peptide-news' ); ?></option>
|
||||
</select>
|
||||
<button type="button" id="pn-cost-refresh" class="button button-secondary" style="margin-left: 8px;">
|
||||
<?php esc_html_e( 'Refresh', 'peptide-news' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Budget Status Card -->
|
||||
<div id="pn-budget-status" class="card" style="max-width: 600px; padding: 15px; margin-bottom: 20px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Monthly Budget', 'peptide-news' ); ?></h2>
|
||||
<div id="pn-budget-content">
|
||||
<p class="description"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards Row -->
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
|
||||
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'Total Cost', 'peptide-news' ); ?></h3>
|
||||
<p id="pn-cost-total" style="font-size: 24px; font-weight: bold; margin: 0;">$0.0000</p>
|
||||
</div>
|
||||
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
|
||||
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'API Requests', 'peptide-news' ); ?></h3>
|
||||
<p id="pn-cost-requests" style="font-size: 24px; font-weight: bold; margin: 0;">0</p>
|
||||
</div>
|
||||
<div class="card" style="flex: 1; min-width: 150px; padding: 15px;">
|
||||
<h3 style="margin: 0 0 5px;"><?php esc_html_e( 'Total Tokens', 'peptide-news' ); ?></h3>
|
||||
<p id="pn-cost-tokens" style="font-size: 24px; font-weight: bold; margin: 0;">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Cost Chart -->
|
||||
<div class="card" style="max-width: 800px; padding: 15px; margin-bottom: 20px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Daily Cost Trend', 'peptide-news' ); ?></h2>
|
||||
<canvas id="pn-cost-chart" height="250"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Breakdowns Row -->
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 20px;">
|
||||
<!-- Per-Model Breakdown -->
|
||||
<div class="card" style="flex: 1; min-width: 350px; padding: 15px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Cost by Model', 'peptide-news' ); ?></h2>
|
||||
<table class="widefat striped" id="pn-cost-by-model">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Model', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Requests', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Tokens', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="4"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Per-Operation Breakdown -->
|
||||
<div class="card" style="flex: 1; min-width: 300px; padding: 15px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Cost by Operation', 'peptide-news' ); ?></h2>
|
||||
<table class="widefat striped" id="pn-cost-by-operation">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Operation', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Requests', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="3"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent API Calls Log -->
|
||||
<div class="card" style="padding: 15px; margin-bottom: 20px;">
|
||||
<h2 style="margin-top: 0;"><?php esc_html_e( 'Recent API Calls', 'peptide-news' ); ?></h2>
|
||||
<table class="widefat striped" id="pn-cost-recent">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Time', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Model', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Operation', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Prompt', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Completion', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Total', 'peptide-news' ); ?></th>
|
||||
<th><?php esc_html_e( 'Cost', 'peptide-news' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="7"><?php esc_html_e( 'Loading...', 'peptide-news' ); ?></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
var costChart = null;
|
||||
|
||||
function loadCostData() {
|
||||
var period = $('#pn-cost-period').val();
|
||||
|
||||
$.ajax({
|
||||
url: peptideNewsAdmin.ajax_url,
|
||||
method: 'GET',
|
||||
data: {
|
||||
action: 'peptide_news_get_cost_data',
|
||||
nonce: peptideNewsAdmin.admin_nonce,
|
||||
period: period
|
||||
},
|
||||
success: function(response) {
|
||||
if (response.success) {
|
||||
renderCostData(response.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostData(data) {
|
||||
// Summary cards.
|
||||
$('#pn-cost-total').text('$' + parseFloat(data.summary.total_cost).toFixed(4));
|
||||
$('#pn-cost-requests').text(data.summary.total_requests.toLocaleString());
|
||||
$('#pn-cost-tokens').text(data.summary.total_tokens.toLocaleString());
|
||||
|
||||
// Budget status.
|
||||
renderBudget(data.budget);
|
||||
|
||||
// Daily chart.
|
||||
renderChart(data.daily_costs);
|
||||
|
||||
// Model breakdown table.
|
||||
renderModelTable(data.summary.by_model);
|
||||
|
||||
// Operation breakdown table.
|
||||
renderOperationTable(data.summary.by_operation);
|
||||
|
||||
// Recent calls log.
|
||||
renderRecentCalls(data.recent_calls);
|
||||
}
|
||||
|
||||
function renderBudget(budget) {
|
||||
var html = '';
|
||||
if (budget.limit <= 0 || budget.mode === 'disabled') {
|
||||
html = '<p>' + '<?php echo esc_js( __( 'No budget set. Configure a monthly budget in Settings to track spending limits.', 'peptide-news' ) ); ?>' + '</p>';
|
||||
} else {
|
||||
var pct = budget.percent_used;
|
||||
var barColor = pct >= 100 ? '#dc3232' : (pct >= 80 ? '#dba617' : '#46b450');
|
||||
var modeLabel = budget.mode === 'hard_stop' ? '<?php echo esc_js( __( 'Hard Stop', 'peptide-news' ) ); ?>' : '<?php echo esc_js( __( 'Warn Only', 'peptide-news' ) ); ?>';
|
||||
|
||||
html += '<p><strong><?php echo esc_js( __( 'Spent:', 'peptide-news' ) ); ?></strong> $' + budget.month_spend.toFixed(4) + ' / $' + budget.limit.toFixed(2) + ' (' + pct.toFixed(1) + '%)</p>';
|
||||
html += '<div style="background:#e0e0e0;border-radius:4px;height:20px;max-width:400px;margin:8px 0;">';
|
||||
html += '<div style="background:' + barColor + ';height:100%;border-radius:4px;width:' + Math.min(pct, 100) + '%;transition:width 0.3s;"></div>';
|
||||
html += '</div>';
|
||||
html += '<p class="description"><strong><?php echo esc_js( __( 'Mode:', 'peptide-news' ) ); ?></strong> ' + modeLabel + '</p>';
|
||||
|
||||
if (pct >= 100 && budget.mode === 'hard_stop') {
|
||||
html += '<div class="notice notice-error inline" style="margin:10px 0;"><p><?php echo esc_js( __( 'Budget exceeded! LLM API calls are blocked until next month or budget is increased.', 'peptide-news' ) ); ?></p></div>';
|
||||
} else if (pct >= 80) {
|
||||
html += '<div class="notice notice-warning inline" style="margin:10px 0;"><p><?php echo esc_js( __( 'Approaching budget limit.', 'peptide-news' ) ); ?></p></div>';
|
||||
}
|
||||
}
|
||||
$('#pn-budget-content').html(html);
|
||||
}
|
||||
|
||||
function renderChart(dailyCosts) {
|
||||
var ctx = document.getElementById('pn-cost-chart');
|
||||
if (!ctx) return;
|
||||
|
||||
var labels = dailyCosts.map(function(d) { return d.date; });
|
||||
var costs = dailyCosts.map(function(d) { return parseFloat(d.cost); });
|
||||
var tokens = dailyCosts.map(function(d) { return parseInt(d.tokens, 10); });
|
||||
|
||||
if (costChart) {
|
||||
costChart.destroy();
|
||||
}
|
||||
|
||||
costChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '<?php echo esc_js( __( 'Cost (USD)', 'peptide-news' ) ); ?>',
|
||||
data: costs,
|
||||
backgroundColor: 'rgba(0, 115, 170, 0.7)',
|
||||
borderColor: '#0073aa',
|
||||
borderWidth: 1,
|
||||
yAxisID: 'y'
|
||||
}, {
|
||||
label: '<?php echo esc_js( __( 'Tokens', 'peptide-news' ) ); ?>',
|
||||
data: tokens,
|
||||
type: 'line',
|
||||
borderColor: '#46b450',
|
||||
backgroundColor: 'rgba(70, 180, 80, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
title: { display: true, text: '<?php echo esc_js( __( 'Cost (USD)', 'peptide-news' ) ); ?>' },
|
||||
ticks: {
|
||||
callback: function(v) { return '$' + v.toFixed(4); }
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
title: { display: true, text: '<?php echo esc_js( __( 'Tokens', 'peptide-news' ) ); ?>' },
|
||||
grid: { drawOnChartArea: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelTable(models) {
|
||||
var tbody = $('#pn-cost-by-model tbody');
|
||||
if (!models || models.length === 0) {
|
||||
tbody.html('<tr><td colspan="4"><?php echo esc_js( __( 'No data for this period.', 'peptide-news' ) ); ?></td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
models.forEach(function(m) {
|
||||
html += '<tr>';
|
||||
html += '<td><code>' + escHtml(m.model) + '</code></td>';
|
||||
html += '<td>' + parseInt(m.requests, 10).toLocaleString() + '</td>';
|
||||
html += '<td>' + parseInt(m.total_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>$' + parseFloat(m.total_cost).toFixed(4) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
function renderOperationTable(ops) {
|
||||
var tbody = $('#pn-cost-by-operation tbody');
|
||||
if (!ops || ops.length === 0) {
|
||||
tbody.html('<tr><td colspan="3"><?php echo esc_js( __( 'No data for this period.', 'peptide-news' ) ); ?></td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
ops.forEach(function(o) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + escHtml(o.operation) + '</td>';
|
||||
html += '<td>' + parseInt(o.requests, 10).toLocaleString() + '</td>';
|
||||
html += '<td>$' + parseFloat(o.total_cost).toFixed(4) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
function renderRecentCalls(calls) {
|
||||
var tbody = $('#pn-cost-recent tbody');
|
||||
if (!calls || calls.length === 0) {
|
||||
tbody.html('<tr><td colspan="7"><?php echo esc_js( __( 'No API calls recorded yet.', 'peptide-news' ) ); ?></td></tr>');
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
calls.forEach(function(c) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + escHtml(c.created_at) + '</td>';
|
||||
html += '<td><code style="font-size:11px;">' + escHtml(c.model) + '</code></td>';
|
||||
html += '<td>' + escHtml(c.operation) + '</td>';
|
||||
html += '<td>' + parseInt(c.prompt_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>' + parseInt(c.completion_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>' + parseInt(c.total_tokens, 10).toLocaleString() + '</td>';
|
||||
html += '<td>$' + parseFloat(c.cost_usd).toFixed(6) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Event handlers.
|
||||
$('#pn-cost-period').on('change', loadCostData);
|
||||
$('#pn-cost-refresh').on('click', loadCostData);
|
||||
|
||||
// Initial load.
|
||||
$(document).ready(loadCostData);
|
||||
|
||||
})(jQuery);
|
||||
</script>
|
||||
2
admin/partials/dashboard.php
Normal file → Executable file
2
admin/partials/dashboard.php
Normal file → Executable file
@@ -5,6 +5,8 @@
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
19
includes/class-peptide-news-activator.php
Normal file → Executable file
19
includes/class-peptide-news-activator.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Fired during plugin activation.
|
||||
*
|
||||
@@ -11,7 +12,7 @@ class Peptide_News_Activator {
|
||||
/**
|
||||
* Create custom tables and schedule cron.
|
||||
*/
|
||||
public static function activate() {
|
||||
public static function activate(): void {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
@@ -20,12 +21,17 @@ class Peptide_News_Activator {
|
||||
if ( class_exists( 'Peptide_News_Logger' ) ) {
|
||||
Peptide_News_Logger::create_table();
|
||||
}
|
||||
|
||||
// Create the LLM cost tracking table (idempotent via dbDelta).
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) ) {
|
||||
Peptide_News_Cost_Tracker::create_table();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the articles and analytics database tables.
|
||||
*/
|
||||
private static function create_tables() {
|
||||
private static function create_tables(): void {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
@@ -107,7 +113,7 @@ class Peptide_News_Activator {
|
||||
/**
|
||||
* Set sensible default option values.
|
||||
*/
|
||||
private static function set_default_options() {
|
||||
private static function set_default_options(): void {
|
||||
$defaults = array(
|
||||
'fetch_interval' => 'twicedaily',
|
||||
'articles_count' => 10,
|
||||
@@ -128,6 +134,9 @@ class Peptide_News_Activator {
|
||||
'llm_keywords_model' => 'google/gemini-2.0-flash-001',
|
||||
'llm_summary_model' => 'google/gemini-2.0-flash-001',
|
||||
'llm_max_articles' => 10,
|
||||
'monthly_budget' => 0,
|
||||
'budget_mode' => 'disabled',
|
||||
'cost_retention' => 365,
|
||||
);
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
@@ -140,7 +149,7 @@ class Peptide_News_Activator {
|
||||
/**
|
||||
* Schedule the cron event based on the configured interval.
|
||||
*/
|
||||
private static function schedule_cron() {
|
||||
private static function schedule_cron(): void {
|
||||
$interval = get_option( 'peptide_news_fetch_interval', 'twicedaily' );
|
||||
|
||||
if ( ! wp_next_scheduled( 'peptide_news_cron_fetch' ) ) {
|
||||
@@ -151,7 +160,7 @@ class Peptide_News_Activator {
|
||||
/**
|
||||
* 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( string $articles_table, string $clicks_table, string $daily_table ): void {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
47
includes/class-peptide-news-analytics.php
Normal file → Executable file
47
includes/class-peptide-news-analytics.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Click analytics tracking and reporting.
|
||||
*
|
||||
@@ -16,7 +17,7 @@ class Peptide_News_Analytics {
|
||||
* @param array $meta Additional context (IP, UA, referrer, etc.).
|
||||
* @return bool
|
||||
*/
|
||||
public static function record_click( $article_id, $meta = array() ) {
|
||||
public static function record_click( int $article_id, array $meta = array() ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
@@ -61,7 +62,7 @@ class Peptide_News_Analytics {
|
||||
* @param int $article_id
|
||||
* @param string $ip Anonymized IP for unique visitor counting.
|
||||
*/
|
||||
private static function update_daily_stats( $article_id, $ip ) {
|
||||
private static function update_daily_stats( int $article_id, string $ip ): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
@@ -114,7 +115,7 @@ class Peptide_News_Analytics {
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public static function get_top_articles( $start_date, $end_date, $limit = 20 ) {
|
||||
public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array {
|
||||
global $wpdb;
|
||||
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
@@ -143,7 +144,7 @@ class Peptide_News_Analytics {
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_click_trends( $start_date, $end_date ) {
|
||||
public static function get_click_trends( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
@@ -167,7 +168,7 @@ class Peptide_News_Analytics {
|
||||
* @param int $limit
|
||||
* @return array
|
||||
*/
|
||||
public static function get_popular_topics( $start_date, $end_date, $limit = 20 ) {
|
||||
public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array {
|
||||
global $wpdb;
|
||||
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
@@ -221,7 +222,7 @@ class Peptide_News_Analytics {
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_device_breakdown( $start_date, $end_date ) {
|
||||
public static function get_device_breakdown( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
@@ -244,7 +245,7 @@ class Peptide_News_Analytics {
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
*/
|
||||
public static function get_source_performance( $start_date, $end_date ) {
|
||||
public static function get_source_performance( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
|
||||
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
|
||||
@@ -265,27 +266,41 @@ class Peptide_News_Analytics {
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard cap on CSV export rows to prevent memory exhaustion on large datasets.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const CSV_EXPORT_MAX_ROWS = 50000;
|
||||
|
||||
/**
|
||||
* Export click data as CSV-ready array.
|
||||
*
|
||||
* @param string $start_date
|
||||
* @param string $end_date
|
||||
* @return array
|
||||
* Capped at CSV_EXPORT_MAX_ROWS to prevent memory exhaustion on sites
|
||||
* with large click volumes. The cap is applied at the SQL level so
|
||||
* only the limited result set is ever loaded into PHP memory.
|
||||
*
|
||||
* @param string $start_date Y-m-d format.
|
||||
* @param string $end_date Y-m-d format.
|
||||
* @return array Array of associative arrays (one per click row).
|
||||
*/
|
||||
public static function export_clicks_csv( $start_date, $end_date ) {
|
||||
public static function export_clicks_csv( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
|
||||
$clicks_table = $wpdb->prefix . 'peptide_news_clicks';
|
||||
$articles_table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
return $wpdb->get_results( $wpdb->prepare(
|
||||
"SELECT c.clicked_at, a.title, a.source, a.source_url, a.categories,
|
||||
c.referrer_url, c.page_url, c.device_type, c.session_id
|
||||
FROM {$clicks_table} c
|
||||
INNER JOIN {$articles_table} a ON a.id = c.article_id
|
||||
WHERE DATE(c.clicked_at) BETWEEN %s AND %s
|
||||
ORDER BY c.clicked_at DESC",
|
||||
ORDER BY c.clicked_at DESC
|
||||
LIMIT %d",
|
||||
$start_date,
|
||||
$end_date
|
||||
$end_date,
|
||||
self::CSV_EXPORT_MAX_ROWS
|
||||
), ARRAY_A );
|
||||
}
|
||||
|
||||
@@ -294,7 +309,7 @@ class Peptide_News_Analytics {
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
private static function get_client_ip() {
|
||||
private static function get_client_ip(): ?string {
|
||||
$ip_keys = array( 'HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR' );
|
||||
|
||||
foreach ( $ip_keys as $key ) {
|
||||
@@ -316,7 +331,7 @@ class Peptide_News_Analytics {
|
||||
* @param string $ip
|
||||
* @return string
|
||||
*/
|
||||
private static function anonymize_ip( $ip ) {
|
||||
private static function anonymize_ip( string $ip ): string {
|
||||
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
|
||||
return preg_replace( '/\.\d+$/', '.0', $ip );
|
||||
}
|
||||
@@ -332,7 +347,7 @@ class Peptide_News_Analytics {
|
||||
* @param string $ua
|
||||
* @return string
|
||||
*/
|
||||
private static function detect_device( $ua ) {
|
||||
private static function detect_device( string $ua ): string {
|
||||
$ua = strtolower( $ua );
|
||||
|
||||
if ( preg_match( '/(tablet|ipad|playbook|silk)/', $ua ) ) {
|
||||
|
||||
38
includes/class-peptide-news-content-filter.php
Normal file → Executable file
38
includes/class-peptide-news-content-filter.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Content filter for detecting and removing ads, press releases,
|
||||
* and promotional content from fetched articles.
|
||||
@@ -122,7 +123,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
public static function is_enabled(): bool {
|
||||
return (bool) get_option( 'peptide_news_filter_enabled', 1 );
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_llm_filter_enabled() {
|
||||
public static function is_llm_filter_enabled(): bool {
|
||||
if ( ! (bool) get_option( 'peptide_news_filter_llm_enabled', 0 ) ) {
|
||||
return false;
|
||||
}
|
||||
@@ -151,7 +152,7 @@ class Peptide_News_Content_Filter {
|
||||
* @param array $articles Array of article arrays from fetch_rss_feeds() / fetch_newsapi().
|
||||
* @return array Filtered articles with promotional content removed.
|
||||
*/
|
||||
public static function filter_articles( $articles ) {
|
||||
public static function filter_articles( array $articles ): array {
|
||||
if ( ! self::is_enabled() || empty( $articles ) ) {
|
||||
return $articles;
|
||||
}
|
||||
@@ -227,7 +228,7 @@ class Peptide_News_Content_Filter {
|
||||
* @param int $body_threshold Minimum body keyword matches to flag.
|
||||
* @return array Array with 'verdict' (clean|borderline|promotional), 'rule', 'score'.
|
||||
*/
|
||||
public static function evaluate_article( $article, $title_keywords, $body_keywords, $blocked_domains, $body_threshold ) {
|
||||
public static function evaluate_article( array $article, array $title_keywords, array $body_keywords, array $blocked_domains, int $body_threshold ): array {
|
||||
$score = 0;
|
||||
$rule = '';
|
||||
|
||||
@@ -343,9 +344,12 @@ class Peptide_News_Content_Filter {
|
||||
* @param array $article Article data array.
|
||||
* @return string 'editorial' or 'promotional'.
|
||||
*/
|
||||
private static function classify_with_llm( $article ) {
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
$model = get_option( 'peptide_news_filter_llm_model', '' );
|
||||
private static function classify_with_llm( array $article ): string {
|
||||
$api_key_raw = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
$api_key = class_exists( 'Peptide_News_Encryption' )
|
||||
? Peptide_News_Encryption::decrypt( $api_key_raw )
|
||||
: $api_key_raw;
|
||||
$model = get_option( 'peptide_news_filter_llm_model', '' );
|
||||
|
||||
// Fall back to the keywords model if no dedicated filter model is set.
|
||||
if ( empty( $model ) ) {
|
||||
@@ -387,7 +391,7 @@ class Peptide_News_Content_Filter {
|
||||
* @param array $article Article data.
|
||||
* @return string Prompt text.
|
||||
*/
|
||||
private static function build_classification_prompt( $article ) {
|
||||
private static function build_classification_prompt( array $article ): string {
|
||||
$title = $article['title'] ?? '';
|
||||
$excerpt = $article['excerpt'] ?? '';
|
||||
$content = $article['content'] ?? '';
|
||||
@@ -420,7 +424,7 @@ class Peptide_News_Content_Filter {
|
||||
* @param string $sensitivity 'strict', 'moderate', or 'lenient'.
|
||||
* @return int Number of body keyword matches required.
|
||||
*/
|
||||
private static function get_body_threshold( $sensitivity ) {
|
||||
private static function get_body_threshold( string $sensitivity ): int {
|
||||
$thresholds = array(
|
||||
'strict' => 1,
|
||||
'moderate' => 2,
|
||||
@@ -435,7 +439,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_title_keywords() {
|
||||
public static function get_title_keywords(): array {
|
||||
$custom = get_option( 'peptide_news_filter_title_keywords', '' );
|
||||
|
||||
if ( ! empty( $custom ) ) {
|
||||
@@ -450,7 +454,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_body_keywords() {
|
||||
public static function get_body_keywords(): array {
|
||||
$custom = get_option( 'peptide_news_filter_body_keywords', '' );
|
||||
|
||||
if ( ! empty( $custom ) ) {
|
||||
@@ -465,7 +469,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_blocked_domains() {
|
||||
public static function get_blocked_domains(): array {
|
||||
$custom = get_option( 'peptide_news_filter_blocked_domains', '' );
|
||||
|
||||
if ( ! empty( $custom ) ) {
|
||||
@@ -480,7 +484,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_default_title_keywords_text() {
|
||||
public static function get_default_title_keywords_text(): string {
|
||||
return implode( "\n", self::$default_title_keywords );
|
||||
}
|
||||
|
||||
@@ -489,7 +493,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_default_body_keywords_text() {
|
||||
public static function get_default_body_keywords_text(): string {
|
||||
return implode( "\n", self::$default_body_keywords );
|
||||
}
|
||||
|
||||
@@ -498,7 +502,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_default_blocked_domains_text() {
|
||||
public static function get_default_blocked_domains_text(): string {
|
||||
return implode( "\n", self::$default_blocked_domains );
|
||||
}
|
||||
|
||||
@@ -509,7 +513,7 @@ class Peptide_News_Content_Filter {
|
||||
* @param int $removed Articles removed by the filter.
|
||||
* @param int $llm_checked Articles sent to LLM for classification.
|
||||
*/
|
||||
private static function update_filter_stats( $total, $removed, $llm_checked ) {
|
||||
private static function update_filter_stats( int $total, int $removed, int $llm_checked ): void {
|
||||
update_option( 'peptide_news_filter_last_run', array(
|
||||
'time' => current_time( 'mysql' ),
|
||||
'total' => $total,
|
||||
@@ -524,7 +528,7 @@ class Peptide_News_Content_Filter {
|
||||
*
|
||||
* @param string $message Log message.
|
||||
*/
|
||||
private static function log( $message ) {
|
||||
private static function log( string $message ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( self::LOG_PREFIX . ' ' . $message );
|
||||
}
|
||||
|
||||
550
includes/class-peptide-news-cost-tracker.php
Executable file
550
includes/class-peptide-news-cost-tracker.php
Executable file
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Tracks LLM API costs: logs every API call, enforces monthly budgets, and
|
||||
* provides aggregated reporting data for the admin cost dashboard.
|
||||
*
|
||||
* Triggered by: Peptide_News_LLM after each call_openrouter() invocation.
|
||||
* Admin AJAX handlers for cost dashboard data.
|
||||
*
|
||||
* Dependencies: $wpdb (custom table), Peptide_News_Logger.
|
||||
*
|
||||
* Data flow: LLM::call_openrouter() returns usage data → Cost_Tracker::log_api_call()
|
||||
* writes to wp_peptide_news_llm_costs table. Budget checks happen
|
||||
* before each LLM call via Cost_Tracker::is_budget_exceeded().
|
||||
* Admin dashboard queries aggregated data via get_cost_summary() and
|
||||
* get_daily_costs().
|
||||
*
|
||||
* @see class-peptide-news-llm.php — Calls log_api_call() after every API request
|
||||
* @see class-peptide-news-admin.php — Renders cost dashboard and budget settings
|
||||
* @see class-peptide-news-activator.php — Creates the llm_costs table on activation
|
||||
* @see ARCHITECTURE.md — Data flow diagram
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
class Peptide_News_Cost_Tracker {
|
||||
|
||||
/**
|
||||
* Default model pricing per 1M tokens (USD).
|
||||
*
|
||||
* These are fallback rates used when the API response doesn't include cost data.
|
||||
* Pricing sourced from OpenRouter's published rates as of April 2026.
|
||||
* Users can override per-model pricing via the admin settings.
|
||||
*
|
||||
* @var array<string, array{input: float, output: float}>
|
||||
*/
|
||||
const DEFAULT_MODEL_PRICING = array(
|
||||
'google/gemini-2.0-flash-001' => array( 'input' => 0.10, 'output' => 0.40 ),
|
||||
'google/gemma-3-27b-it:free' => array( 'input' => 0.00, 'output' => 0.00 ),
|
||||
'anthropic/claude-3.5-sonnet' => array( 'input' => 3.00, 'output' => 15.00 ),
|
||||
'anthropic/claude-3-haiku' => array( 'input' => 0.25, 'output' => 1.25 ),
|
||||
'openai/gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ),
|
||||
'openai/gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ),
|
||||
);
|
||||
|
||||
/** @var string Budget enforcement mode: 'hard_stop', 'warn_only', or 'disabled'. */
|
||||
const BUDGET_MODE_HARD_STOP = 'hard_stop';
|
||||
const BUDGET_MODE_WARN_ONLY = 'warn_only';
|
||||
const BUDGET_MODE_DISABLED = 'disabled';
|
||||
|
||||
/**
|
||||
* Create the llm_costs table. Called from Activator::activate().
|
||||
*
|
||||
* Uses dbDelta() for idempotent CREATE/ALTER so it's safe to call on every
|
||||
* plugin upgrade without manual migration logic.
|
||||
*/
|
||||
public static function create_table(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_llm_costs';
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE {$table} (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
request_id VARCHAR(64) NOT NULL DEFAULT '',
|
||||
model VARCHAR(100) NOT NULL DEFAULT '',
|
||||
provider VARCHAR(50) NOT NULL DEFAULT 'openrouter',
|
||||
operation VARCHAR(50) NOT NULL DEFAULT '',
|
||||
prompt_tokens INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
completion_tokens INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
total_tokens INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0.000000,
|
||||
article_id BIGINT(20) UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_created_at (created_at),
|
||||
KEY idx_model (model),
|
||||
KEY idx_operation (operation),
|
||||
KEY idx_article (article_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a single API call's token usage and cost.
|
||||
*
|
||||
* Called by LLM::call_openrouter() after a successful (or failed) API response.
|
||||
* Extracts token counts from the OpenRouter response's `usage` field and
|
||||
* calculates cost using model-specific pricing.
|
||||
*
|
||||
* @param string $model OpenRouter model ID (e.g., 'google/gemini-2.0-flash-001').
|
||||
* @param string $operation What this call was for: 'keywords', 'summary', or 'filter'.
|
||||
* @param array $usage Token usage from API response: {prompt_tokens, completion_tokens, total_tokens}.
|
||||
* @param int|null $article_id Associated article ID, if applicable.
|
||||
* @param string $request_id OpenRouter request ID from response headers (for debugging).
|
||||
* @param float $explicit_cost Cost reported directly by the API (OpenRouter generation.total_cost), if available.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public static function log_api_call(
|
||||
string $model,
|
||||
string $operation,
|
||||
array $usage,
|
||||
?int $article_id = null,
|
||||
string $request_id = '',
|
||||
float $explicit_cost = 0.0
|
||||
): bool {
|
||||
global $wpdb;
|
||||
|
||||
$prompt_tokens = absint( $usage['prompt_tokens'] ?? 0 );
|
||||
$completion_tokens = absint( $usage['completion_tokens'] ?? 0 );
|
||||
$total_tokens = absint( $usage['total_tokens'] ?? ( $prompt_tokens + $completion_tokens ) );
|
||||
|
||||
// Use explicit cost from API if available, otherwise calculate from pricing table.
|
||||
$cost_usd = $explicit_cost > 0.0
|
||||
? $explicit_cost
|
||||
: self::calculate_cost( $model, $prompt_tokens, $completion_tokens );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$result = $wpdb->insert(
|
||||
$wpdb->prefix . 'peptide_news_llm_costs',
|
||||
array(
|
||||
'request_id' => sanitize_text_field( $request_id ),
|
||||
'model' => sanitize_text_field( $model ),
|
||||
'provider' => 'openrouter',
|
||||
'operation' => sanitize_text_field( $operation ),
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'total_tokens' => $total_tokens,
|
||||
'cost_usd' => $cost_usd,
|
||||
'article_id' => $article_id,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%f', '%d', '%s' )
|
||||
);
|
||||
|
||||
if ( false === $result ) {
|
||||
Peptide_News_Logger::error( 'Failed to log API cost for model ' . $model, 'cost' );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check budget thresholds and fire alerts if needed.
|
||||
self::check_budget_alerts();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate estimated cost from token counts using model pricing.
|
||||
*
|
||||
* Pricing is per 1M tokens. Checks user-configured overrides first,
|
||||
* then falls back to DEFAULT_MODEL_PRICING, then to zero (free model assumed).
|
||||
*
|
||||
* @param string $model OpenRouter model ID.
|
||||
* @param int $prompt_tokens Number of input tokens.
|
||||
* @param int $completion_tokens Number of output tokens.
|
||||
* @return float Estimated cost in USD.
|
||||
*/
|
||||
public static function calculate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
|
||||
$pricing = self::get_model_pricing( $model );
|
||||
|
||||
$input_cost = ( $prompt_tokens / 1000000.0 ) * $pricing['input'];
|
||||
$output_cost = ( $completion_tokens / 1000000.0 ) * $pricing['output'];
|
||||
|
||||
return round( $input_cost + $output_cost, 6 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing for a specific model (per 1M tokens).
|
||||
*
|
||||
* Checks user-configured custom pricing first, then falls back to defaults.
|
||||
*
|
||||
* @param string $model OpenRouter model ID.
|
||||
* @return array{input: float, output: float}
|
||||
*/
|
||||
public static function get_model_pricing( string $model ): array {
|
||||
// Check user-configured custom pricing overrides.
|
||||
$custom_pricing = get_option( 'peptide_news_custom_model_pricing', array() );
|
||||
if ( is_array( $custom_pricing ) && isset( $custom_pricing[ $model ] ) ) {
|
||||
return array(
|
||||
'input' => (float) ( $custom_pricing[ $model ]['input'] ?? 0.0 ),
|
||||
'output' => (float) ( $custom_pricing[ $model ]['output'] ?? 0.0 ),
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to built-in defaults.
|
||||
if ( isset( self::DEFAULT_MODEL_PRICING[ $model ] ) ) {
|
||||
return self::DEFAULT_MODEL_PRICING[ $model ];
|
||||
}
|
||||
|
||||
// Unknown model — assume free to avoid overcharging estimates.
|
||||
return array( 'input' => 0.0, 'output' => 0.0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the monthly budget has been exceeded.
|
||||
*
|
||||
* Should be called before every LLM API call to enforce the hard stop.
|
||||
* Returns true if budget mode is 'hard_stop' and current month's spend
|
||||
* meets or exceeds the configured limit.
|
||||
*
|
||||
* @return bool True if budget is exceeded and calls should be blocked.
|
||||
*/
|
||||
public static function is_budget_exceeded(): bool {
|
||||
$mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED );
|
||||
$limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
|
||||
if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( self::BUDGET_MODE_HARD_STOP !== $mode ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$current_spend = self::get_current_month_spend();
|
||||
|
||||
return $current_spend >= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total spend for the current calendar month.
|
||||
*
|
||||
* Uses a short-lived transient cache (5 min) to avoid hammering the DB
|
||||
* on every budget check during batch processing.
|
||||
*
|
||||
* @return float Total USD spent this month.
|
||||
*/
|
||||
public static function get_current_month_spend(): float {
|
||||
$cache_key = 'peptide_news_month_spend_' . gmdate( 'Y_m' );
|
||||
$cached = get_transient( $cache_key );
|
||||
|
||||
if ( false !== $cached ) {
|
||||
return (float) $cached;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_llm_costs';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$spend = (float) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COALESCE(SUM(cost_usd), 0) FROM {$table} WHERE created_at >= %s",
|
||||
gmdate( 'Y-m-01 00:00:00' )
|
||||
)
|
||||
);
|
||||
|
||||
set_transient( $cache_key, $spend, 5 * MINUTE_IN_SECONDS );
|
||||
|
||||
return $spend;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the monthly spend cache.
|
||||
*
|
||||
* Called after logging a new API call so the next budget check
|
||||
* reflects the latest spend.
|
||||
*/
|
||||
public static function invalidate_spend_cache(): void {
|
||||
delete_transient( 'peptide_news_month_spend_' . gmdate( 'Y_m' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check budget threshold alerts and log warnings.
|
||||
*
|
||||
* Fires at 50%, 80%, and 100% of the monthly budget. Each threshold
|
||||
* is only logged once per month (tracked via a transient).
|
||||
*/
|
||||
private static function check_budget_alerts(): void {
|
||||
$mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED );
|
||||
$limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
|
||||
if ( self::BUDGET_MODE_DISABLED === $mode || $limit <= 0.0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate cache so we get fresh numbers.
|
||||
self::invalidate_spend_cache();
|
||||
|
||||
$spend = self::get_current_month_spend();
|
||||
$percent = ( $spend / $limit ) * 100.0;
|
||||
|
||||
$alert_key = 'peptide_news_budget_alerts_' . gmdate( 'Y_m' );
|
||||
$fired = get_transient( $alert_key );
|
||||
if ( ! is_array( $fired ) ) {
|
||||
$fired = array();
|
||||
}
|
||||
|
||||
$thresholds = array( 50, 80, 100 );
|
||||
|
||||
foreach ( $thresholds as $threshold ) {
|
||||
if ( $percent >= $threshold && ! in_array( $threshold, $fired, true ) ) {
|
||||
$message = sprintf(
|
||||
'LLM budget alert: %.0f%% of $%.2f monthly budget used ($%.4f spent).',
|
||||
$percent,
|
||||
$limit,
|
||||
$spend
|
||||
);
|
||||
Peptide_News_Logger::warning( $message, 'cost' );
|
||||
|
||||
if ( 100 === $threshold && self::BUDGET_MODE_HARD_STOP === $mode ) {
|
||||
Peptide_News_Logger::error( 'Monthly LLM budget exceeded — API calls will be blocked until next month.', 'cost' );
|
||||
}
|
||||
|
||||
$fired[] = $threshold;
|
||||
}
|
||||
}
|
||||
|
||||
set_transient( $alert_key, $fired, 35 * DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an aggregated cost summary for a date range.
|
||||
*
|
||||
* Returns total spend, total tokens, request count, and per-model breakdown.
|
||||
* Used by the admin cost dashboard.
|
||||
*
|
||||
* @param string $start_date Start date (Y-m-d format).
|
||||
* @param string $end_date End date (Y-m-d format).
|
||||
* @return array{total_cost: float, total_tokens: int, total_requests: int, by_model: array, by_operation: array}
|
||||
*/
|
||||
public static function get_cost_summary( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_llm_costs';
|
||||
|
||||
// Overall totals.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$totals = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
COALESCE(SUM(cost_usd), 0) AS total_cost,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COUNT(*) AS total_requests
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND created_at < %s",
|
||||
$start_date . ' 00:00:00',
|
||||
$end_date . ' 23:59:59'
|
||||
)
|
||||
);
|
||||
|
||||
// Per-model breakdown.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$by_model = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
model,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(cost_usd), 0) AS total_cost
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC",
|
||||
$start_date . ' 00:00:00',
|
||||
$end_date . ' 23:59:59'
|
||||
)
|
||||
);
|
||||
|
||||
// Per-operation breakdown.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$by_operation = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
operation,
|
||||
COUNT(*) AS requests,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
COALESCE(SUM(cost_usd), 0) AS total_cost
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY operation
|
||||
ORDER BY total_cost DESC",
|
||||
$start_date . ' 00:00:00',
|
||||
$end_date . ' 23:59:59'
|
||||
)
|
||||
);
|
||||
|
||||
return array(
|
||||
'total_cost' => (float) ( $totals->total_cost ?? 0 ),
|
||||
'total_tokens' => (int) ( $totals->total_tokens ?? 0 ),
|
||||
'total_requests' => (int) ( $totals->total_requests ?? 0 ),
|
||||
'by_model' => ! empty( $by_model ) ? $by_model : array(),
|
||||
'by_operation' => ! empty( $by_operation ) ? $by_operation : array(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily cost data for charting.
|
||||
*
|
||||
* Returns one row per day within the date range with aggregated cost and token counts.
|
||||
*
|
||||
* @param string $start_date Start date (Y-m-d).
|
||||
* @param string $end_date End date (Y-m-d).
|
||||
* @return array Array of objects with {date, cost, tokens, requests}.
|
||||
*/
|
||||
public static function get_daily_costs( string $start_date, string $end_date ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_llm_costs';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
DATE(created_at) AS date,
|
||||
COALESCE(SUM(cost_usd), 0) AS cost,
|
||||
COALESCE(SUM(total_tokens), 0) AS tokens,
|
||||
COUNT(*) AS requests
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC",
|
||||
$start_date . ' 00:00:00',
|
||||
$end_date . ' 23:59:59'
|
||||
)
|
||||
);
|
||||
|
||||
return ! empty( $results ) ? $results : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent API calls for the cost log table.
|
||||
*
|
||||
* @param int $limit Number of recent entries to return.
|
||||
* @return array Array of cost log rows.
|
||||
*/
|
||||
public static function get_recent_calls( int $limit = 50 ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_llm_costs';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d",
|
||||
$limit
|
||||
)
|
||||
);
|
||||
|
||||
return ! empty( $results ) ? $results : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: return cost dashboard data.
|
||||
*
|
||||
* Accepts 'period' param: 'day', 'week', 'month', or 'custom' with start/end dates.
|
||||
* Returns summary stats and daily chart data.
|
||||
*
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public static function ajax_get_cost_data(): void {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( 'Unauthorized', 403 );
|
||||
}
|
||||
|
||||
$period = sanitize_text_field( wp_unslash( $_GET['period'] ?? 'month' ) );
|
||||
|
||||
switch ( $period ) {
|
||||
case 'day':
|
||||
$start_date = gmdate( 'Y-m-d' );
|
||||
$end_date = gmdate( 'Y-m-d' );
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
$start_date = gmdate( 'Y-m-d', strtotime( '-6 days' ) );
|
||||
$end_date = gmdate( 'Y-m-d' );
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
$raw_start = sanitize_text_field( wp_unslash( $_GET['start_date'] ?? '' ) );
|
||||
$raw_end = sanitize_text_field( wp_unslash( $_GET['end_date'] ?? '' ) );
|
||||
$start_date = self::validate_date( $raw_start ) ? $raw_start : gmdate( 'Y-m-01' );
|
||||
$end_date = self::validate_date( $raw_end ) ? $raw_end : gmdate( 'Y-m-d' );
|
||||
break;
|
||||
|
||||
case 'month':
|
||||
default:
|
||||
$start_date = gmdate( 'Y-m-01' );
|
||||
$end_date = gmdate( 'Y-m-d' );
|
||||
break;
|
||||
}
|
||||
|
||||
$summary = self::get_cost_summary( $start_date, $end_date );
|
||||
$daily_costs = self::get_daily_costs( $start_date, $end_date );
|
||||
$recent = self::get_recent_calls( 25 );
|
||||
|
||||
// Budget context.
|
||||
$budget_limit = (float) get_option( 'peptide_news_monthly_budget', 0.0 );
|
||||
$budget_mode = get_option( 'peptide_news_budget_mode', self::BUDGET_MODE_DISABLED );
|
||||
$month_spend = self::get_current_month_spend();
|
||||
|
||||
wp_send_json_success( array(
|
||||
'summary' => $summary,
|
||||
'daily_costs' => $daily_costs,
|
||||
'recent_calls' => $recent,
|
||||
'budget' => array(
|
||||
'limit' => $budget_limit,
|
||||
'mode' => $budget_mode,
|
||||
'month_spend' => $month_spend,
|
||||
'percent_used' => $budget_limit > 0 ? round( ( $month_spend / $budget_limit ) * 100, 1 ) : 0,
|
||||
),
|
||||
'period' => array(
|
||||
'start' => $start_date,
|
||||
'end' => $end_date,
|
||||
),
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a date string is in Y-m-d format and represents a real date.
|
||||
*
|
||||
* @param string $date Date string to validate.
|
||||
* @return bool True if valid Y-m-d date.
|
||||
*/
|
||||
private static function validate_date( string $date ): bool {
|
||||
$d = \DateTime::createFromFormat( 'Y-m-d', $date );
|
||||
return $d && $d->format( 'Y-m-d' ) === $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old cost records beyond the configured retention period.
|
||||
*
|
||||
* Called on a monthly cron schedule. Default retention: 365 days.
|
||||
*
|
||||
* @return int Number of rows deleted.
|
||||
*/
|
||||
public static function prune_old_records(): int {
|
||||
$retention_days = absint( get_option( 'peptide_news_cost_retention', 365 ) );
|
||||
if ( $retention_days < 30 ) {
|
||||
$retention_days = 30; // Minimum 30 days to prevent accidental data loss.
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_llm_costs';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$deleted = $wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"DELETE FROM {$table} WHERE created_at < %s",
|
||||
gmdate( 'Y-m-d 00:00:00', strtotime( "-{$retention_days} days" ) )
|
||||
)
|
||||
);
|
||||
|
||||
if ( $deleted > 0 ) {
|
||||
Peptide_News_Logger::info( sprintf( 'Pruned %d cost records older than %d days.', $deleted, $retention_days ), 'cost' );
|
||||
}
|
||||
|
||||
return (int) $deleted;
|
||||
}
|
||||
}
|
||||
3
includes/class-peptide-news-deactivator.php
Normal file → Executable file
3
includes/class-peptide-news-deactivator.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Fired during plugin deactivation.
|
||||
*
|
||||
@@ -8,7 +9,7 @@
|
||||
*/
|
||||
class Peptide_News_Deactivator {
|
||||
|
||||
public static function deactivate() {
|
||||
public static function deactivate(): void {
|
||||
wp_clear_scheduled_hook( 'peptide_news_cron_fetch' );
|
||||
}
|
||||
}
|
||||
|
||||
174
includes/class-peptide-news-encryption.php
Executable file
174
includes/class-peptide-news-encryption.php
Executable file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* AES-256-CBC encryption for sensitive option values (API keys).
|
||||
*
|
||||
* Encrypts data at rest in wp_options using AES-256-CBC with WordPress
|
||||
* auth salt as the key material. Transparent encrypt/decrypt API so
|
||||
* callers don't need to know the cipher details.
|
||||
*
|
||||
* Triggered by: Admin settings save (class-peptide-news-admin.php),
|
||||
* and read by any class that retrieves API keys.
|
||||
*
|
||||
* Dependencies: OpenSSL PHP extension (standard on all modern hosts).
|
||||
*
|
||||
* @since 2.3.0
|
||||
* @see class-peptide-news-admin.php — Encrypts keys on save via sanitize callback.
|
||||
* @see class-peptide-news-llm.php — Decrypts OpenRouter key before API calls.
|
||||
* @see class-peptide-news-fetcher.php — Decrypts NewsAPI key before API calls.
|
||||
* @see ARCHITECTURE.md — Security section documents encryption strategy.
|
||||
*/
|
||||
class Peptide_News_Encryption {
|
||||
|
||||
/**
|
||||
* Cipher algorithm used for encryption.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CIPHER = 'aes-256-cbc';
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string.
|
||||
*
|
||||
* Returns a base64-encoded string containing the IV prepended to the
|
||||
* ciphertext. Returns the original value if OpenSSL is unavailable or
|
||||
* encryption fails (fail-open so the plugin still works on hosts
|
||||
* without OpenSSL, just without at-rest encryption).
|
||||
*
|
||||
* @param string $plaintext The value to encrypt.
|
||||
* @return string Encrypted value (base64) or original plaintext on failure.
|
||||
*/
|
||||
public static function encrypt( string $plaintext ): string {
|
||||
if ( empty( $plaintext ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( ! self::is_available() ) {
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
$key = self::get_key();
|
||||
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
||||
$iv = openssl_random_pseudo_bytes( $iv_length );
|
||||
|
||||
$ciphertext = openssl_encrypt( $plaintext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
||||
|
||||
if ( false === $ciphertext ) {
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
// Prepend IV to ciphertext and base64-encode for safe storage.
|
||||
return base64_encode( $iv . $ciphertext ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a previously encrypted string.
|
||||
*
|
||||
* Handles both encrypted (base64) and legacy plaintext values
|
||||
* gracefully, so existing unencrypted keys continue to work
|
||||
* during the migration period.
|
||||
*
|
||||
* @param string $encrypted The encrypted value (or legacy plaintext).
|
||||
* @return string Decrypted plaintext.
|
||||
*/
|
||||
public static function decrypt( string $encrypted ): string {
|
||||
if ( empty( $encrypted ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( ! self::is_available() ) {
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
// Detect legacy plaintext: if it doesn't look like base64, return as-is.
|
||||
// Valid encrypted values are always base64 and at least 24 chars
|
||||
// (16-byte IV + minimum 1-byte ciphertext, base64-encoded).
|
||||
if ( ! self::looks_encrypted( $encrypted ) ) {
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
$raw = base64_decode( $encrypted, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
|
||||
|
||||
if ( false === $raw ) {
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
$key = self::get_key();
|
||||
$iv_length = openssl_cipher_iv_length( self::CIPHER );
|
||||
|
||||
if ( strlen( $raw ) <= $iv_length ) {
|
||||
// Too short to contain IV + ciphertext — treat as plaintext.
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
$iv = substr( $raw, 0, $iv_length );
|
||||
$ciphertext = substr( $raw, $iv_length );
|
||||
|
||||
$plaintext = openssl_decrypt( $ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv );
|
||||
|
||||
if ( false === $plaintext ) {
|
||||
// Decryption failed — likely a legacy plaintext value that
|
||||
// happened to look like base64. Return the original.
|
||||
return $encrypted;
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a stored value looks like it was encrypted by this class.
|
||||
*
|
||||
* Used to distinguish encrypted values from legacy plaintext API keys
|
||||
* during the migration period. Encrypted values are base64-encoded
|
||||
* and at least 24 characters long (16-byte IV + ciphertext).
|
||||
*
|
||||
* @param string $value The stored option value.
|
||||
* @return bool True if the value appears to be encrypted.
|
||||
*/
|
||||
public static function looks_encrypted( string $value ): bool {
|
||||
if ( strlen( $value ) < 24 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be valid base64.
|
||||
if ( ! preg_match( '/^[A-Za-z0-9+\/]+=*$/', $value ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// API keys typically start with "sk-" or similar prefixes
|
||||
// and contain characters that aren't valid base64.
|
||||
// If it starts with a known API key prefix, it's plaintext.
|
||||
$plaintext_prefixes = array( 'sk-', 'pk-', 'key-', 'Bearer ' );
|
||||
foreach ( $plaintext_prefixes as $prefix ) {
|
||||
if ( strpos( $value, $prefix ) === 0 ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the OpenSSL extension is available with the required cipher.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available(): bool {
|
||||
return function_exists( 'openssl_encrypt' )
|
||||
&& in_array( self::CIPHER, openssl_get_cipher_methods(), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the encryption key from WordPress auth salt.
|
||||
*
|
||||
* Uses SHA-256 hash of the auth salt to ensure a consistent
|
||||
* 32-byte key regardless of the salt's actual length.
|
||||
*
|
||||
* @return string 32-byte binary key.
|
||||
*/
|
||||
private static function get_key(): string {
|
||||
// wp_salt('auth') returns the AUTH_KEY + AUTH_SALT concatenation.
|
||||
// Hash it to get a consistent 32-byte key for AES-256.
|
||||
return hash( 'sha256', wp_salt( 'auth' ), true );
|
||||
}
|
||||
}
|
||||
38
includes/class-peptide-news-fetcher.php
Normal file → Executable file
38
includes/class-peptide-news-fetcher.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Fetches news from multiple sources and stores them in the database.
|
||||
*
|
||||
@@ -16,7 +17,7 @@ class Peptide_News_Fetcher {
|
||||
* @param array $schedules Existing schedules.
|
||||
* @return array
|
||||
*/
|
||||
public function add_custom_cron_schedules( $schedules ) {
|
||||
public function add_custom_cron_schedules( array $schedules ): array {
|
||||
$schedules['every_fifteen_minutes'] = array(
|
||||
'interval' => 900,
|
||||
'display' => __( 'Every 15 Minutes', 'peptide-news' ),
|
||||
@@ -40,7 +41,7 @@ class Peptide_News_Fetcher {
|
||||
* Master fetch method — called by WP-Cron.
|
||||
* Uses a transient lock to prevent concurrent executions.
|
||||
*/
|
||||
public function fetch_all_sources() {
|
||||
public function fetch_all_sources(): void {
|
||||
// Prevent overlapping fetch jobs.
|
||||
$lock_key = 'peptide_news_fetch_lock';
|
||||
if ( get_transient( $lock_key ) ) {
|
||||
@@ -61,7 +62,10 @@ class Peptide_News_Fetcher {
|
||||
|
||||
// Fetch from NewsAPI.
|
||||
if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) {
|
||||
$api_key = get_option( 'peptide_news_newsapi_key', '' );
|
||||
$api_key_raw = get_option( 'peptide_news_newsapi_key', '' );
|
||||
$api_key = class_exists( 'Peptide_News_Encryption' )
|
||||
? Peptide_News_Encryption::decrypt( $api_key_raw )
|
||||
: $api_key_raw;
|
||||
if ( ! empty( $api_key ) ) {
|
||||
$newsapi_articles = $this->fetch_newsapi( $api_key );
|
||||
$articles = array_merge( $articles, $newsapi_articles );
|
||||
@@ -128,7 +132,7 @@ class Peptide_News_Fetcher {
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function fetch_rss_feeds() {
|
||||
private function fetch_rss_feeds(): array {
|
||||
$feeds_raw = get_option( 'peptide_news_rss_feeds', '' );
|
||||
$feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) );
|
||||
$articles = array();
|
||||
@@ -175,7 +179,7 @@ class Peptide_News_Fetcher {
|
||||
* @param string $feed_url
|
||||
* @return array Article data or empty array if processing failed.
|
||||
*/
|
||||
private function process_rss_item( $item, $feed_url ) {
|
||||
private function process_rss_item( $item, string $feed_url ): array {
|
||||
$pub_date = $item->get_date( 'Y-m-d H:i:s' );
|
||||
if ( empty( $pub_date ) ) {
|
||||
$pub_date = current_time( 'mysql' );
|
||||
@@ -209,7 +213,7 @@ class Peptide_News_Fetcher {
|
||||
* @param string $api_key
|
||||
* @return array
|
||||
*/
|
||||
private function fetch_newsapi( $api_key ) {
|
||||
private function fetch_newsapi( string $api_key ): array {
|
||||
$keywords = get_option( 'peptide_news_search_keywords', 'peptide research' );
|
||||
$articles = array();
|
||||
|
||||
@@ -267,7 +271,7 @@ class Peptide_News_Fetcher {
|
||||
* @param array $article
|
||||
* @return bool True if new article was inserted.
|
||||
*/
|
||||
private function store_article( $article ) {
|
||||
private function store_article( array $article ): bool {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
@@ -310,7 +314,7 @@ class Peptide_News_Fetcher {
|
||||
/**
|
||||
* Remove articles older than the retention period.
|
||||
*/
|
||||
private function prune_old_articles() {
|
||||
private function prune_old_articles(): void {
|
||||
global $wpdb;
|
||||
|
||||
$retention_days = (int) get_option( 'peptide_news_article_retention', 90 );
|
||||
@@ -326,7 +330,7 @@ class Peptide_News_Fetcher {
|
||||
/**
|
||||
* Clear all article transient caches.
|
||||
*/
|
||||
private function clear_article_cache() {
|
||||
private function clear_article_cache(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
@@ -342,7 +346,7 @@ class Peptide_News_Fetcher {
|
||||
/**
|
||||
* Invalidate REST API response caches matching the articles pattern.
|
||||
*/
|
||||
private function invalidate_rest_api_cache() {
|
||||
private function invalidate_rest_api_cache(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
@@ -361,7 +365,7 @@ class Peptide_News_Fetcher {
|
||||
* @param SimplePie_Item $item
|
||||
* @return string Comma-separated categories.
|
||||
*/
|
||||
private function extract_categories( $item ) {
|
||||
private function extract_categories( $item ): string {
|
||||
$categories = $item->get_categories();
|
||||
if ( empty( $categories ) ) {
|
||||
return '';
|
||||
@@ -393,7 +397,7 @@ class Peptide_News_Fetcher {
|
||||
* @param string $article_url The article permalink.
|
||||
* @return string Source name or domain.
|
||||
*/
|
||||
private function resolve_article_source( $item, $feed_url, $article_url ) {
|
||||
private function resolve_article_source( $item, string $feed_url, string $article_url ): string {
|
||||
$feed_domain = $this->extract_domain( $feed_url );
|
||||
$is_aggregator = in_array(
|
||||
$feed_domain,
|
||||
@@ -455,7 +459,7 @@ class Peptide_News_Fetcher {
|
||||
* @param string $url The URL to resolve.
|
||||
* @return string|false The final URL or false on failure.
|
||||
*/
|
||||
private function resolve_redirect_url( $url ) {
|
||||
private function resolve_redirect_url( string $url ) {
|
||||
if ( empty( $url ) ) {
|
||||
return false;
|
||||
}
|
||||
@@ -505,7 +509,7 @@ class Peptide_News_Fetcher {
|
||||
*
|
||||
* @return int Number of articles updated.
|
||||
*/
|
||||
public function backfill_article_sources() {
|
||||
public function backfill_article_sources(): int {
|
||||
global $wpdb;
|
||||
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
@@ -572,7 +576,7 @@ class Peptide_News_Fetcher {
|
||||
/**
|
||||
* AJAX handler for the backfill action.
|
||||
*/
|
||||
public function ajax_backfill_sources() {
|
||||
public function ajax_backfill_sources(): void {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
@@ -589,7 +593,7 @@ class Peptide_News_Fetcher {
|
||||
* @param string $url
|
||||
* @return string
|
||||
*/
|
||||
private function extract_domain( $url ) {
|
||||
private function extract_domain( string $url ): string {
|
||||
$host = wp_parse_url( $url, PHP_URL_HOST );
|
||||
return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown';
|
||||
}
|
||||
@@ -599,7 +603,7 @@ class Peptide_News_Fetcher {
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
private function log_error( $message ) {
|
||||
private function log_error( string $message ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( '[Peptide News] ' . $message );
|
||||
}
|
||||
|
||||
201
includes/class-peptide-news-llm.php
Normal file → Executable file
201
includes/class-peptide-news-llm.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* LLM integration via OpenRouter for article analysis.
|
||||
*
|
||||
@@ -20,7 +21,7 @@ class Peptide_News_LLM {
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function get_api_url() {
|
||||
private static function get_api_url(): string {
|
||||
return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' );
|
||||
}
|
||||
|
||||
@@ -29,11 +30,30 @@ class Peptide_News_LLM {
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
public static function is_enabled(): bool {
|
||||
$api_key = self::get_api_key();
|
||||
return ! empty( $api_key ) && (bool) get_option( 'peptide_news_llm_enabled', 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and decrypt the OpenRouter API key.
|
||||
*
|
||||
* Handles both encrypted (AES-256-CBC) and legacy plaintext values
|
||||
* transparently via Peptide_News_Encryption::decrypt().
|
||||
*
|
||||
* @return string Decrypted API key or empty string.
|
||||
*/
|
||||
private static function get_api_key(): string {
|
||||
$raw = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
if ( empty( $raw ) ) {
|
||||
return '';
|
||||
}
|
||||
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
||||
return Peptide_News_Encryption::decrypt( $raw );
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an OpenRouter model ID format.
|
||||
*
|
||||
@@ -42,7 +62,7 @@ class Peptide_News_LLM {
|
||||
* @param string $model
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_valid_model( $model ) {
|
||||
public static function is_valid_model( string $model ): bool {
|
||||
return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model );
|
||||
}
|
||||
|
||||
@@ -50,12 +70,13 @@ class Peptide_News_LLM {
|
||||
* Process a single article: extract keywords and generate a summary.
|
||||
*
|
||||
* Skips articles that already have both tags and ai_summary unless $force is true.
|
||||
* Checks budget before each API call and logs costs via Cost_Tracker.
|
||||
*
|
||||
* @param object $article Database row with id, title, excerpt, content, categories.
|
||||
* @param bool $force Re-process even if already analyzed.
|
||||
* @return array Results with 'keywords', 'summary', and 'success' keys.
|
||||
*/
|
||||
public static function process_article( $article, $force = false ) {
|
||||
public static function process_article( object $article, bool $force = false ): array {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return array( 'keywords' => '', 'summary' => '', 'success' => false );
|
||||
}
|
||||
@@ -67,25 +88,44 @@ class Peptide_News_LLM {
|
||||
'errors' => array(),
|
||||
);
|
||||
|
||||
$api_key = get_option( 'peptide_news_openrouter_api_key', '' );
|
||||
$api_key = self::get_api_key();
|
||||
$any_success = false;
|
||||
$article_id = (int) $article->id;
|
||||
|
||||
// --- Keyword extraction ---
|
||||
$keywords_model = get_option( 'peptide_news_llm_keywords_model', 'google/gemini-2.0-flash-001' );
|
||||
$need_keywords = $force || empty( $article->tags );
|
||||
|
||||
if ( $need_keywords && self::is_valid_model( $keywords_model ) ) {
|
||||
$keywords_prompt = self::build_keywords_prompt( $article );
|
||||
$keywords_result = self::call_openrouter( $api_key, $keywords_model, $keywords_prompt );
|
||||
|
||||
if ( ! is_wp_error( $keywords_result ) ) {
|
||||
$results['keywords'] = self::sanitize_keywords( $keywords_result );
|
||||
$any_success = true;
|
||||
Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article->id, 'llm' );
|
||||
// Budget gate: block if monthly limit reached.
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) {
|
||||
$results['errors'][] = 'Monthly LLM budget exceeded — keywords skipped.';
|
||||
Peptide_News_Logger::warning( 'Budget exceeded, skipping keywords for article #' . $article_id, 'cost' );
|
||||
} else {
|
||||
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_result->get_error_message();
|
||||
$results['errors'][] = $err_msg;
|
||||
Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article->id . ': ' . $keywords_result->get_error_message(), 'llm' );
|
||||
$keywords_prompt = self::build_keywords_prompt( $article );
|
||||
$keywords_response = self::call_openrouter_with_usage( $api_key, $keywords_model, $keywords_prompt );
|
||||
|
||||
if ( ! is_wp_error( $keywords_response['content'] ) ) {
|
||||
$results['keywords'] = self::sanitize_keywords( $keywords_response['content'] );
|
||||
$any_success = true;
|
||||
Peptide_News_Logger::debug( 'Keywords extracted for article #' . $article_id, 'llm' );
|
||||
} else {
|
||||
$err_msg = 'Keywords (' . $keywords_model . '): ' . $keywords_response['content']->get_error_message();
|
||||
$results['errors'][] = $err_msg;
|
||||
Peptide_News_Logger::error( 'Keyword extraction failed for article #' . $article_id . ': ' . $keywords_response['content']->get_error_message(), 'llm' );
|
||||
}
|
||||
|
||||
// Log cost regardless of success — failed calls still consume tokens.
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $keywords_response['usage'] ) ) {
|
||||
Peptide_News_Cost_Tracker::log_api_call(
|
||||
$keywords_model,
|
||||
'keywords',
|
||||
$keywords_response['usage'],
|
||||
$article_id,
|
||||
$keywords_response['request_id'] ?? '',
|
||||
$keywords_response['cost'] ?? 0.0
|
||||
);
|
||||
}
|
||||
}
|
||||
} elseif ( $need_keywords ) {
|
||||
$results['errors'][] = 'Invalid keywords model ID: ' . $keywords_model;
|
||||
@@ -97,18 +137,36 @@ class Peptide_News_LLM {
|
||||
$need_summary = $force || empty( $article->ai_summary );
|
||||
|
||||
if ( $need_summary && self::is_valid_model( $summary_model ) ) {
|
||||
$summary_prompt = self::build_summary_prompt( $article );
|
||||
$summary_result = self::call_openrouter( $api_key, $summary_model, $summary_prompt );
|
||||
|
||||
if ( ! is_wp_error( $summary_result ) ) {
|
||||
// Strip all HTML tags from LLM output to prevent XSS.
|
||||
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_result ) );
|
||||
$any_success = true;
|
||||
Peptide_News_Logger::info( 'AI summary generated for article #' . $article->id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
|
||||
// Budget gate: re-check in case keyword extraction pushed us over.
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && Peptide_News_Cost_Tracker::is_budget_exceeded() ) {
|
||||
$results['errors'][] = 'Monthly LLM budget exceeded — summary skipped.';
|
||||
Peptide_News_Logger::warning( 'Budget exceeded, skipping summary for article #' . $article_id, 'cost' );
|
||||
} else {
|
||||
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_result->get_error_message();
|
||||
$results['errors'][] = $err_msg;
|
||||
Peptide_News_Logger::error( 'Summarization failed for article #' . $article->id . ': ' . $summary_result->get_error_message(), 'llm' );
|
||||
$summary_prompt = self::build_summary_prompt( $article );
|
||||
$summary_response = self::call_openrouter_with_usage( $api_key, $summary_model, $summary_prompt );
|
||||
|
||||
if ( ! is_wp_error( $summary_response['content'] ) ) {
|
||||
// Strip all HTML tags from LLM output to prevent XSS.
|
||||
$results['summary'] = sanitize_textarea_field( wp_strip_all_tags( $summary_response['content'] ) );
|
||||
$any_success = true;
|
||||
Peptide_News_Logger::info( 'AI summary generated for article #' . $article_id . ': ' . mb_substr( $article->title, 0, 60 ), 'llm' );
|
||||
} else {
|
||||
$err_msg = 'Summary (' . $summary_model . '): ' . $summary_response['content']->get_error_message();
|
||||
$results['errors'][] = $err_msg;
|
||||
Peptide_News_Logger::error( 'Summarization failed for article #' . $article_id . ': ' . $summary_response['content']->get_error_message(), 'llm' );
|
||||
}
|
||||
|
||||
// Log cost regardless of success.
|
||||
if ( class_exists( 'Peptide_News_Cost_Tracker' ) && ! empty( $summary_response['usage'] ) ) {
|
||||
Peptide_News_Cost_Tracker::log_api_call(
|
||||
$summary_model,
|
||||
'summary',
|
||||
$summary_response['usage'],
|
||||
$article_id,
|
||||
$summary_response['request_id'] ?? '',
|
||||
$summary_response['cost'] ?? 0.0
|
||||
);
|
||||
}
|
||||
}
|
||||
} elseif ( $need_summary ) {
|
||||
$results['errors'][] = 'Invalid summary model ID: ' . $summary_model;
|
||||
@@ -119,7 +177,7 @@ class Peptide_News_LLM {
|
||||
|
||||
// --- Persist results ---
|
||||
if ( ! empty( $results['keywords'] ) || ! empty( $results['summary'] ) ) {
|
||||
self::save_results( $article->id, $results, $force );
|
||||
self::save_results( $article_id, $results, $force );
|
||||
}
|
||||
|
||||
return $results;
|
||||
@@ -136,7 +194,7 @@ class Peptide_News_LLM {
|
||||
* @param bool $override_limit When true, ignore the admin "max per cycle" cap.
|
||||
* @return int Number of articles successfully processed.
|
||||
*/
|
||||
public static function process_unanalyzed( $batch_size = 10, $override_limit = false ) {
|
||||
public static function process_unanalyzed( int $batch_size = 10, bool $override_limit = false ): int {
|
||||
if ( ! self::is_enabled() ) {
|
||||
return 0;
|
||||
}
|
||||
@@ -221,7 +279,7 @@ class Peptide_News_LLM {
|
||||
* @param object $article
|
||||
* @return string
|
||||
*/
|
||||
private static function build_keywords_prompt( $article ) {
|
||||
private static function build_keywords_prompt( object $article ): string {
|
||||
$text = $article->title;
|
||||
if ( ! empty( $article->excerpt ) ) {
|
||||
$text .= "\n\n" . $article->excerpt;
|
||||
@@ -245,7 +303,7 @@ class Peptide_News_LLM {
|
||||
* @param object $article
|
||||
* @return string
|
||||
*/
|
||||
private static function build_summary_prompt( $article ) {
|
||||
private static function build_summary_prompt( object $article ): string {
|
||||
$title = trim( $article->title ?? '' );
|
||||
$excerpt = trim( $article->excerpt ?? '' );
|
||||
$content = trim( $article->content ?? '' );
|
||||
@@ -287,7 +345,7 @@ class Peptide_News_LLM {
|
||||
* @param string $url The article URL.
|
||||
* @return string Cleaned plain text or empty string on failure.
|
||||
*/
|
||||
private static function fetch_article_text( $url ) {
|
||||
private static function fetch_article_text( string $url ): string {
|
||||
$response = wp_remote_get( $url, array(
|
||||
'timeout' => 10,
|
||||
'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0; +https://peptiderepo.com)',
|
||||
@@ -337,12 +395,38 @@ class Peptide_News_LLM {
|
||||
/**
|
||||
* Call the OpenRouter API with retry logic for 429 rate limits.
|
||||
*
|
||||
* Backward-compatible wrapper that returns only the content string.
|
||||
* Internally delegates to call_openrouter_with_usage().
|
||||
*
|
||||
* @param string $api_key
|
||||
* @param string $model
|
||||
* @param string $prompt
|
||||
* @return string|WP_Error The response text or an error.
|
||||
*/
|
||||
public static function call_openrouter( $api_key, $model, $prompt ) {
|
||||
public static function call_openrouter( string $api_key, string $model, string $prompt ) {
|
||||
$result = self::call_openrouter_with_usage( $api_key, $model, $prompt );
|
||||
return $result['content'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the OpenRouter API and return content + token usage data.
|
||||
*
|
||||
* Returns an array with 'content' (string|WP_Error), 'usage' (token counts),
|
||||
* 'request_id' (for debugging), and 'cost' (if API reports it).
|
||||
*
|
||||
* @param string $api_key
|
||||
* @param string $model
|
||||
* @param string $prompt
|
||||
* @return array{content: string|WP_Error, usage: array, request_id: string, cost: float}
|
||||
*/
|
||||
public static function call_openrouter_with_usage( string $api_key, string $model, string $prompt ): array {
|
||||
$result = array(
|
||||
'content' => '',
|
||||
'usage' => array(),
|
||||
'request_id' => '',
|
||||
'cost' => 0.0,
|
||||
);
|
||||
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => array(
|
||||
@@ -371,7 +455,8 @@ class Peptide_News_LLM {
|
||||
) );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
$result['content'] = $response;
|
||||
return $result;
|
||||
}
|
||||
|
||||
$status = wp_remote_retrieve_response_code( $response );
|
||||
@@ -391,29 +476,55 @@ class Peptide_News_LLM {
|
||||
|
||||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
// Extract usage data from response regardless of success/failure.
|
||||
// OpenRouter includes usage in the response body: data.usage.{prompt_tokens, completion_tokens, total_tokens}
|
||||
if ( is_array( $data ) && isset( $data['usage'] ) && is_array( $data['usage'] ) ) {
|
||||
$result['usage'] = array(
|
||||
'prompt_tokens' => (int) ( $data['usage']['prompt_tokens'] ?? 0 ),
|
||||
'completion_tokens' => (int) ( $data['usage']['completion_tokens'] ?? 0 ),
|
||||
'total_tokens' => (int) ( $data['usage']['total_tokens'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the request ID from response for debugging/reconciliation.
|
||||
if ( is_array( $data ) && isset( $data['id'] ) ) {
|
||||
$result['request_id'] = (string) $data['id'];
|
||||
}
|
||||
|
||||
// OpenRouter may include generation cost in data.usage.total_cost or similar.
|
||||
if ( is_array( $data ) && isset( $data['usage']['total_cost'] ) ) {
|
||||
$result['cost'] = (float) $data['usage']['total_cost'];
|
||||
}
|
||||
|
||||
if ( $status < 200 || $status >= 300 ) {
|
||||
$error_msg = 'HTTP ' . $status;
|
||||
if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) {
|
||||
$error_msg = $data['error']['message'];
|
||||
}
|
||||
return new WP_Error( 'openrouter_error', $error_msg );
|
||||
$result['content'] = new WP_Error( 'openrouter_error', $error_msg );
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Validate the response structure thoroughly.
|
||||
if ( ! is_array( $data ) ) {
|
||||
return new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' );
|
||||
$result['content'] = new WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' );
|
||||
return $result;
|
||||
}
|
||||
if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) {
|
||||
return new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' );
|
||||
$result['content'] = new WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' );
|
||||
return $result;
|
||||
}
|
||||
if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) {
|
||||
return new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' );
|
||||
$result['content'] = new WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' );
|
||||
return $result;
|
||||
}
|
||||
if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) {
|
||||
return new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' );
|
||||
$result['content'] = new WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' );
|
||||
return $result;
|
||||
}
|
||||
|
||||
return trim( $data['choices'][0]['message']['content'] );
|
||||
$result['content'] = trim( $data['choices'][0]['message']['content'] );
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,7 +533,7 @@ class Peptide_News_LLM {
|
||||
* @param string $raw Raw comma-separated keywords.
|
||||
* @return string Cleaned, deduplicated, comma-separated list.
|
||||
*/
|
||||
private static function sanitize_keywords( $raw ) {
|
||||
private static function sanitize_keywords( string $raw ): string {
|
||||
// Strip all HTML first to prevent XSS.
|
||||
$raw = wp_strip_all_tags( $raw );
|
||||
|
||||
@@ -451,7 +562,7 @@ class Peptide_News_LLM {
|
||||
* @param array $results Array with 'keywords' and 'summary'.
|
||||
* @param bool $force Overwrite existing values.
|
||||
*/
|
||||
private static function save_results( $article_id, $results, $force = false ) {
|
||||
private static function save_results( int $article_id, array $results, bool $force = false ): void {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||
|
||||
@@ -487,7 +598,7 @@ class Peptide_News_LLM {
|
||||
*
|
||||
* @since 2.0.1
|
||||
*/
|
||||
public static function ajax_generate_summaries() {
|
||||
public static function ajax_generate_summaries(): void {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( esc_html__( 'Unauthorized', 'peptide-news' ), 403 );
|
||||
@@ -557,7 +668,7 @@ class Peptide_News_LLM {
|
||||
/**
|
||||
* Clear all article transient caches.
|
||||
*/
|
||||
private static function clear_article_cache() {
|
||||
private static function clear_article_cache(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
@@ -575,7 +686,7 @@ class Peptide_News_LLM {
|
||||
*
|
||||
* @param string $message
|
||||
*/
|
||||
private static function log_error( $message ) {
|
||||
private static function log_error( string $message ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( '[Peptide News LLM] ' . $message );
|
||||
}
|
||||
|
||||
9
includes/class-peptide-news-loader.php
Normal file → Executable file
9
includes/class-peptide-news-loader.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Register all actions and filters for the plugin.
|
||||
*
|
||||
@@ -50,7 +51,7 @@ class Peptide_News_Loader {
|
||||
* @param int $priority Optional. The priority at which the function should be fired. Default is 10.
|
||||
* @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1.
|
||||
*/
|
||||
public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
|
||||
public function add_action( string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1 ): void {
|
||||
$this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args );
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ class Peptide_News_Loader {
|
||||
* @param int $priority Optional. The priority at which the function should be fired. Default is 10.
|
||||
* @param int $accepted_args Optional. The number of arguments that should be passed to the $callback. Default is 1.
|
||||
*/
|
||||
public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
|
||||
public function add_filter( string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1 ): void {
|
||||
$this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args );
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ class Peptide_News_Loader {
|
||||
* @param int $accepted_args The number of arguments that should be passed to the $callback.
|
||||
* @return array The collection of actions and filters registered with WordPress.
|
||||
*/
|
||||
private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) {
|
||||
private function add( array $hooks, string $hook, object $component, string $callback, int $priority, int $accepted_args ): array {
|
||||
$hooks[] = array(
|
||||
'hook' => $hook,
|
||||
'component' => $component,
|
||||
@@ -98,7 +99,7 @@ class Peptide_News_Loader {
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function run() {
|
||||
public function run(): void {
|
||||
foreach ( $this->filters as $hook ) {
|
||||
add_filter(
|
||||
$hook['hook'],
|
||||
|
||||
25
includes/class-peptide-news-logger.php
Normal file → Executable file
25
includes/class-peptide-news-logger.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Plugin Logger.
|
||||
*
|
||||
@@ -21,7 +22,7 @@ class Peptide_News_Logger {
|
||||
/**
|
||||
* Return the log table name.
|
||||
*/
|
||||
public static function table() {
|
||||
public static function table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'peptide_news_log';
|
||||
}
|
||||
@@ -37,7 +38,7 @@ class Peptide_News_Logger {
|
||||
* @param string $message Human-readable message.
|
||||
* @param string $context Category tag (fetch, llm, admin, cron, general).
|
||||
*/
|
||||
public static function log( $level, $message, $context = 'general' ) {
|
||||
public static function log( string $level, string $message, string $context = 'general' ): void {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->insert(
|
||||
@@ -56,22 +57,22 @@ class Peptide_News_Logger {
|
||||
}
|
||||
|
||||
/** Convenience: info level. */
|
||||
public static function info( $message, $context = 'general' ) {
|
||||
public static function info( string $message, string $context = 'general' ): void {
|
||||
self::log( self::LEVEL_INFO, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: warning level. */
|
||||
public static function warning( $message, $context = 'general' ) {
|
||||
public static function warning( string $message, string $context = 'general' ): void {
|
||||
self::log( self::LEVEL_WARNING, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: error level. */
|
||||
public static function error( $message, $context = 'general' ) {
|
||||
public static function error( string $message, string $context = 'general' ): void {
|
||||
self::log( self::LEVEL_ERROR, $message, $context );
|
||||
}
|
||||
|
||||
/** Convenience: debug level (only writes when WP_DEBUG is on). */
|
||||
public static function debug( $message, $context = 'general' ) {
|
||||
public static function debug( string $message, string $context = 'general' ): void {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
self::log( self::LEVEL_DEBUG, $message, $context );
|
||||
}
|
||||
@@ -94,7 +95,7 @@ class Peptide_News_Logger {
|
||||
* }
|
||||
* @return array { 'rows' => array, 'total' => int }
|
||||
*/
|
||||
public static function get_logs( $args = array() ) {
|
||||
public static function get_logs( array $args = array() ): array {
|
||||
global $wpdb;
|
||||
|
||||
$defaults = array(
|
||||
@@ -145,7 +146,7 @@ class Peptide_News_Logger {
|
||||
/**
|
||||
* Remove rows beyond the MAX_ROWS cap.
|
||||
*/
|
||||
private static function maybe_prune() {
|
||||
private static function maybe_prune(): void {
|
||||
global $wpdb;
|
||||
$table = self::table();
|
||||
|
||||
@@ -168,7 +169,7 @@ class Peptide_News_Logger {
|
||||
/**
|
||||
* Clear all log entries.
|
||||
*/
|
||||
public static function clear() {
|
||||
public static function clear(): void {
|
||||
global $wpdb;
|
||||
$wpdb->query( "TRUNCATE TABLE " . self::table() );
|
||||
}
|
||||
@@ -180,7 +181,7 @@ class Peptide_News_Logger {
|
||||
/**
|
||||
* AJAX: fetch paginated log entries.
|
||||
*/
|
||||
public static function ajax_get_logs() {
|
||||
public static function ajax_get_logs(): void {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
@@ -210,7 +211,7 @@ class Peptide_News_Logger {
|
||||
/**
|
||||
* AJAX: clear all log entries.
|
||||
*/
|
||||
public static function ajax_clear_logs() {
|
||||
public static function ajax_clear_logs(): void {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
@@ -230,7 +231,7 @@ class Peptide_News_Logger {
|
||||
/**
|
||||
* Create the log table (called from activator / upgrade check).
|
||||
*/
|
||||
public static function create_table() {
|
||||
public static function create_table(): void {
|
||||
global $wpdb;
|
||||
|
||||
$table = self::table();
|
||||
|
||||
9
includes/class-peptide-news-rest-api.php
Normal file → Executable file
9
includes/class-peptide-news-rest-api.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* REST API endpoints for external analytics access.
|
||||
*
|
||||
@@ -22,7 +23,7 @@ class Peptide_News_Rest_API {
|
||||
/**
|
||||
* Register REST routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
public function register_routes(): void {
|
||||
|
||||
// Public: list articles.
|
||||
register_rest_route( self::API_NAMESPACE, '/articles', array(
|
||||
@@ -83,7 +84,7 @@ class Peptide_News_Rest_API {
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_date_range_args() {
|
||||
private function get_date_range_args(): array {
|
||||
return array(
|
||||
'start_date' => array(
|
||||
'default' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
|
||||
@@ -216,7 +217,7 @@ class Peptide_News_Rest_API {
|
||||
* @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 = '' ) {
|
||||
private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string {
|
||||
if ( empty( $text ) ) {
|
||||
return $text;
|
||||
}
|
||||
@@ -387,7 +388,7 @@ class Peptide_News_Rest_API {
|
||||
*
|
||||
* @since 2.1.0
|
||||
*/
|
||||
public static function ajax_delete_articles() {
|
||||
public static function ajax_delete_articles(): void {
|
||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
|
||||
22
includes/class-peptide-news.php
Normal file → Executable file
22
includes/class-peptide-news.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* The core plugin class.
|
||||
*
|
||||
@@ -31,16 +32,18 @@ class Peptide_News {
|
||||
/**
|
||||
* Load all required files.
|
||||
*/
|
||||
private function load_dependencies() {
|
||||
private function load_dependencies(): void {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-loader.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-encryption.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-logger.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-content-filter.php';
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.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';
|
||||
|
||||
@@ -50,7 +53,7 @@ class Peptide_News {
|
||||
/**
|
||||
* Register admin-side hooks.
|
||||
*/
|
||||
private function define_admin_hooks() {
|
||||
private function define_admin_hooks(): void {
|
||||
$admin = new Peptide_News_Admin( $this->plugin_name, $this->version );
|
||||
|
||||
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' );
|
||||
@@ -62,7 +65,7 @@ class Peptide_News {
|
||||
/**
|
||||
* Register public-facing hooks.
|
||||
*/
|
||||
private function define_public_hooks() {
|
||||
private function define_public_hooks(): void {
|
||||
$public = new Peptide_News_Public( $this->plugin_name, $this->version );
|
||||
|
||||
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' );
|
||||
@@ -76,7 +79,7 @@ class Peptide_News {
|
||||
/**
|
||||
* Register WP-Cron hooks for scheduled fetching.
|
||||
*/
|
||||
private function define_cron_hooks() {
|
||||
private function define_cron_hooks(): void {
|
||||
$fetcher = new Peptide_News_Fetcher();
|
||||
|
||||
$this->loader->add_action( 'peptide_news_cron_fetch', $fetcher, 'fetch_all_sources' );
|
||||
@@ -86,12 +89,13 @@ class Peptide_News {
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_get_logs', 'Peptide_News_Logger', 'ajax_get_logs' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_clear_logs', 'Peptide_News_Logger', 'ajax_clear_logs' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_delete_articles', 'Peptide_News_Rest_API', 'ajax_delete_articles' );
|
||||
$this->loader->add_action( 'wp_ajax_peptide_news_get_cost_data', 'Peptide_News_Cost_Tracker', 'ajax_get_cost_data' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API hooks.
|
||||
*/
|
||||
private function define_rest_hooks() {
|
||||
private function define_rest_hooks(): void {
|
||||
$rest = new Peptide_News_Rest_API();
|
||||
|
||||
$this->loader->add_action( 'rest_api_init', $rest, 'register_routes' );
|
||||
@@ -100,19 +104,19 @@ class Peptide_News {
|
||||
/**
|
||||
* Run the loader to execute all registered hooks.
|
||||
*/
|
||||
public function run() {
|
||||
public function run(): void {
|
||||
$this->loader->run();
|
||||
}
|
||||
|
||||
public function get_plugin_name() {
|
||||
public function get_plugin_name(): string {
|
||||
return $this->plugin_name;
|
||||
}
|
||||
|
||||
public function get_version() {
|
||||
public function get_version(): string {
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function get_loader() {
|
||||
public function get_loader(): Peptide_News_Loader {
|
||||
return $this->loader;
|
||||
}
|
||||
}
|
||||
|
||||
0
languages/peptide-news.pot
Normal file → Executable file
0
languages/peptide-news.pot
Normal file → Executable file
14
peptide-news-plugin.php
Normal file → Executable file
14
peptide-news-plugin.php
Normal file → Executable file
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: Peptide News Aggregator
|
||||
* Plugin URI: https://github.com/peptiderepo/peptide-news-plugin
|
||||
* Description: Aggregates and displays the latest peptide research news from multiple sources with click analytics and trend reporting.
|
||||
* Version: 2.2.0
|
||||
* Version: 2.4.0
|
||||
* Author: Peptide News Team
|
||||
* Author URI: https://github.com/peptiderepo
|
||||
* License: GPL-2.0+
|
||||
@@ -14,6 +14,8 @@
|
||||
* Requires PHP: 7.4
|
||||
*/
|
||||
|
||||
declare( strict_types=1 );
|
||||
|
||||
// Abort if called directly.
|
||||
if ( ! defined( 'WPINC' ) ) {
|
||||
die;
|
||||
@@ -22,7 +24,7 @@ if ( ! defined( 'WPINC' ) ) {
|
||||
/**
|
||||
* Current plugin version — follows SemVer.
|
||||
*/
|
||||
define( 'PEPTIDE_NEWS_VERSION', '2.2.0' );
|
||||
define( 'PEPTIDE_NEWS_VERSION', '2.4.0' );
|
||||
define( 'PEPTIDE_NEWS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'PEPTIDE_NEWS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
@@ -30,7 +32,7 @@ define( 'PEPTIDE_NEWS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||
/**
|
||||
* Runs during plugin activation.
|
||||
*/
|
||||
function peptide_news_activate() {
|
||||
function peptide_news_activate(): void {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
Peptide_News_Activator::activate();
|
||||
}
|
||||
@@ -38,7 +40,7 @@ function peptide_news_activate() {
|
||||
/**
|
||||
* Runs during plugin deactivation.
|
||||
*/
|
||||
function peptide_news_deactivate() {
|
||||
function peptide_news_deactivate(): void {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||
Peptide_News_Deactivator::deactivate();
|
||||
}
|
||||
@@ -56,7 +58,7 @@ require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news.php';
|
||||
* This handles existing installs that upgrade from 1.0.0 to 1.1.0
|
||||
* without re-activating the plugin.
|
||||
*/
|
||||
function peptide_news_check_db_upgrade() {
|
||||
function peptide_news_check_db_upgrade(): void {
|
||||
$installed_version = get_option( 'peptide_news_db_version', '1.0.0' );
|
||||
if ( version_compare( $installed_version, PEPTIDE_NEWS_VERSION, '<' ) ) {
|
||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-activator.php';
|
||||
@@ -69,7 +71,7 @@ add_action( 'plugins_loaded', 'peptide_news_check_db_upgrade' );
|
||||
/**
|
||||
* Begins execution of the plugin.
|
||||
*/
|
||||
function peptide_news_run() {
|
||||
function peptide_news_run(): void {
|
||||
$plugin = new Peptide_News();
|
||||
$plugin->run();
|
||||
}
|
||||
|
||||
17
public/class-peptide-news-public.php
Normal file → Executable file
17
public/class-peptide-news-public.php
Normal file → Executable file
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare( strict_types=1 );
|
||||
/**
|
||||
* Public-facing functionality.
|
||||
*
|
||||
@@ -16,7 +17,7 @@ class Peptide_News_Public {
|
||||
/** @var string */
|
||||
private $version;
|
||||
|
||||
public function __construct( $plugin_name, $version ) {
|
||||
public function __construct( string $plugin_name, string $version ) {
|
||||
$this->plugin_name = $plugin_name;
|
||||
$this->version = $version;
|
||||
}
|
||||
@@ -24,7 +25,7 @@ class Peptide_News_Public {
|
||||
/**
|
||||
* Register front-end CSS (enqueued on demand by the shortcode/widget).
|
||||
*/
|
||||
public function enqueue_styles() {
|
||||
public function enqueue_styles(): void {
|
||||
wp_register_style(
|
||||
$this->plugin_name,
|
||||
PEPTIDE_NEWS_PLUGIN_URL . 'public/css/public-style.css',
|
||||
@@ -40,7 +41,7 @@ class Peptide_News_Public {
|
||||
* Loads React (from WordPress core), the feed component, and
|
||||
* passes configuration via wp_localize_script.
|
||||
*/
|
||||
public function enqueue_scripts() {
|
||||
public function enqueue_scripts(): void {
|
||||
// React feed component (depends on WP-bundled React).
|
||||
wp_register_script(
|
||||
$this->plugin_name . '-feed',
|
||||
@@ -54,14 +55,14 @@ class Peptide_News_Public {
|
||||
/**
|
||||
* Register the shortcode.
|
||||
*/
|
||||
public function register_shortcode() {
|
||||
public function register_shortcode(): void {
|
||||
add_shortcode( 'peptide_news', array( $this, 'render_shortcode' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the widget.
|
||||
*/
|
||||
public function register_widget() {
|
||||
public function register_widget(): void {
|
||||
register_widget( 'Peptide_News_Widget' );
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ class Peptide_News_Public {
|
||||
* @param array $atts Shortcode attributes.
|
||||
* @return string HTML output.
|
||||
*/
|
||||
public function render_shortcode( $atts ) {
|
||||
public function render_shortcode( $atts ): string {
|
||||
$atts = shortcode_atts( array(
|
||||
'count' => get_option( 'peptide_news_articles_count', 10 ),
|
||||
), $atts, 'peptide_news' );
|
||||
@@ -102,7 +103,7 @@ class Peptide_News_Public {
|
||||
/**
|
||||
* Handle AJAX click tracking with basic rate limiting.
|
||||
*/
|
||||
public function handle_click_tracking() {
|
||||
public function handle_click_tracking(): void {
|
||||
check_ajax_referer( 'peptide_news_track', 'nonce' );
|
||||
|
||||
$article_id = isset( $_POST['article_id'] ) ? absint( $_POST['article_id'] ) : 0;
|
||||
@@ -142,7 +143,7 @@ class Peptide_News_Public {
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function get_or_create_session_id() {
|
||||
private function get_or_create_session_id(): string {
|
||||
if ( isset( $_COOKIE['pn_session_id'] ) ) {
|
||||
return sanitize_text_field( wp_unslash( $_COOKIE['pn_session_id'] ) );
|
||||
}
|
||||
|
||||
0
public/css/public-style.css
Normal file → Executable file
0
public/css/public-style.css
Normal file → Executable file
0
public/images/default-thumb.png
Normal file → Executable file
0
public/images/default-thumb.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
0
public/js/peptide-news-feed.js
Normal file → Executable file
0
public/js/peptide-news-feed.js
Normal file → Executable file
0
public/js/public-script.js
Normal file → Executable file
0
public/js/public-script.js
Normal file → Executable file
0
public/react-app/PeptideNewsFeed.jsx
Normal file → Executable file
0
public/react-app/PeptideNewsFeed.jsx
Normal file → Executable file
Reference in New Issue
Block a user