Deploy e11da61 from main

This commit is contained in:
github-actions[bot]
2026-04-24 08:27:29 +00:00
parent 319aa83117
commit f74c8cb23b
8 changed files with 638 additions and 621 deletions

View File

@@ -0,0 +1,196 @@
<?php
declare( strict_types=1 );
/**
* Read-only analytics reporting queries.
*
* Provides aggregated data for the admin analytics dashboard and REST API:
* top articles, click trends, popular topics, device breakdown, source
* performance, and CSV export.
*
* Called by: Peptide_News_Rest_API analytics endpoints, admin dashboard.
* Dependencies: $wpdb (reads from peptide_news_clicks and peptide_news_daily_stats).
*
* @since 2.6.0
* @see class-peptide-news-analytics.php — Click recording (write path).
* @see class-peptide-news-rest-api.php — REST endpoints that consume these queries.
*/
class Peptide_News_Analytics_Reports {
/** Hard cap on CSV export rows to prevent memory exhaustion. */
const CSV_EXPORT_MAX_ROWS = 50000;
/**
* Get top articles by click count within a date range.
*
* @param string $start_date Y-m-d.
* @param string $end_date Y-m-d.
* @param int $limit Max results.
* @return array
*/
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';
$articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare(
"SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at,
SUM(s.click_count) AS total_clicks,
SUM(s.unique_visitors) AS total_unique
FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s
GROUP BY a.id
ORDER BY total_clicks DESC
LIMIT %d",
$start_date, $end_date, $limit
) );
}
/**
* Get click trends over time (daily totals).
*
* @param string $start_date Y-m-d.
* @param string $end_date Y-m-d.
* @return array
*/
public static function get_click_trends( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats';
return $wpdb->get_results( $wpdb->prepare(
"SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
GROUP BY stat_date
ORDER BY stat_date ASC",
$start_date, $end_date
) );
}
/**
* Get popular topics/categories by click volume.
*
* Aggregates clicks across articles sharing the same category.
*
* @param string $start_date Y-m-d.
* @param string $end_date Y-m-d.
* @param int $limit Max topics.
* @return array
*/
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';
$articles_table = $wpdb->prefix . 'peptide_news_articles';
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT a.categories, a.title, SUM(s.click_count) AS clicks
FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s AND a.categories != ''
GROUP BY a.id
ORDER BY clicks DESC",
$start_date, $end_date
) );
$topics = array();
foreach ( $rows as $row ) {
$cats = array_map( 'trim', explode( ',', $row->categories ) );
foreach ( $cats as $cat ) {
if ( empty( $cat ) ) {
continue;
}
$key = strtolower( $cat );
if ( ! isset( $topics[ $key ] ) ) {
$topics[ $key ] = array( 'topic' => $cat, 'total_clicks' => 0, 'article_count' => 0 );
}
$topics[ $key ]['total_clicks'] += (int) $row->clicks;
$topics[ $key ]['article_count'] += 1;
}
}
usort( $topics, function ( $a, $b ) {
return $b['total_clicks'] - $a['total_clicks'];
} );
return array_slice( $topics, 0, $limit );
}
/**
* Get device breakdown stats.
*
* @param string $start_date Y-m-d.
* @param string $end_date Y-m-d.
* @return array
*/
public static function get_device_breakdown( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_clicks';
return $wpdb->get_results( $wpdb->prepare(
"SELECT device_type, COUNT(*) AS click_count
FROM {$table}
WHERE DATE(clicked_at) BETWEEN %s AND %s
GROUP BY device_type
ORDER BY click_count DESC",
$start_date, $end_date
) );
}
/**
* Get source performance stats.
*
* @param string $start_date Y-m-d.
* @param string $end_date Y-m-d.
* @return array
*/
public static function get_source_performance( string $start_date, string $end_date ): array {
global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare(
"SELECT a.source,
COUNT(DISTINCT a.id) AS article_count,
SUM(s.click_count) AS total_clicks,
ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article
FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s
GROUP BY a.source
ORDER BY total_clicks DESC",
$start_date, $end_date
) );
}
/**
* Export click data as CSV-ready array.
*
* Capped at CSV_EXPORT_MAX_ROWS at the SQL level.
*
* @param string $start_date Y-m-d.
* @param string $end_date Y-m-d.
* @return array Associative arrays (one per click row).
*/
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
LIMIT %d",
$start_date, $end_date, self::CSV_EXPORT_MAX_ROWS
), ARRAY_A );
}
}

View File

@@ -1,54 +1,59 @@
<?php
declare( strict_types=1 );
/**
* Click analytics tracking and reporting.
* Click analytics — recording and aggregation.
*
* Handles recording click events, aggregating daily stats,
* and generating trend/topic reports.
* Handles recording click events from the frontend, updating daily
* aggregate stats, and providing utility methods for IP handling
* and device detection.
*
* Called by: Peptide_News_Public::handle_click_tracking() (AJAX endpoint).
* Dependencies: $wpdb (writes to peptide_news_clicks and peptide_news_daily_stats).
*
* @since 1.0.0
* @see class-peptide-news-analytics-reports.php — Read-only reporting queries.
* @see class-peptide-news-rest-api.php — REST endpoints for analytics data.
*/
class Peptide_News_Analytics {
/**
* Record a click event for an article.
*
* @param int $article_id
* @param array $meta Additional context (IP, UA, referrer, etc.).
* @return bool
* Side effects: DB insert to clicks table, daily stats upsert.
*
* @param int $article_id Article ID that was clicked.
* @param array $meta Context: ip, user_agent, referrer, page_url, session_id.
* @return bool True on success.
*/
public static function record_click( int $article_id, array $meta = array() ): bool {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_clicks';
$ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip();
$ip = isset( $meta['ip'] ) ? $meta['ip'] : self::get_client_ip();
// Skip recording if no valid IP is available.
if ( null === $ip ) {
return false;
}
// Optionally anonymize IP (GDPR-friendly).
// GDPR-friendly IP anonymization.
if ( get_option( 'peptide_news_anonymize_ip', 1 ) ) {
$ip = self::anonymize_ip( $ip );
}
$data = array(
'article_id' => absint( $article_id ),
'clicked_at' => current_time( 'mysql' ),
'user_ip' => sanitize_text_field( $ip ),
'user_agent' => sanitize_text_field( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ),
'referrer_url' => esc_url_raw( isset( $meta['referrer'] ) ? $meta['referrer'] : '' ),
'page_url' => esc_url_raw( isset( $meta['page_url'] ) ? $meta['page_url'] : '' ),
'session_id' => sanitize_text_field( isset( $meta['session_id'] ) ? $meta['session_id'] : '' ),
'user_id' => is_user_logged_in() ? get_current_user_id() : null,
'device_type' => sanitize_text_field( self::detect_device( isset( $meta['user_agent'] ) ? $meta['user_agent'] : '' ) ),
'article_id' => absint( $article_id ),
'clicked_at' => current_time( 'mysql' ),
'user_ip' => sanitize_text_field( $ip ),
'user_agent' => sanitize_text_field( $meta['user_agent'] ?? '' ),
'referrer_url' => esc_url_raw( $meta['referrer'] ?? '' ),
'page_url' => esc_url_raw( $meta['page_url'] ?? '' ),
'session_id' => sanitize_text_field( $meta['session_id'] ?? '' ),
'user_id' => is_user_logged_in() ? get_current_user_id() : null,
'device_type' => sanitize_text_field( self::detect_device( $meta['user_agent'] ?? '' ) ),
);
$result = $wpdb->insert( $table, $data );
// Update daily aggregate.
if ( false !== $result ) {
self::update_daily_stats( $article_id, $ip );
}
@@ -57,265 +62,59 @@ class Peptide_News_Analytics {
}
/**
* Update daily aggregated stats.
* Upsert daily aggregated stats for an article.
*
* @param int $article_id
* @param string $ip Anonymized IP for unique visitor counting.
* @param int $article_id Article ID.
* @param string $ip Anonymized IP for unique visitor counting.
*/
private static function update_daily_stats( int $article_id, string $ip ): void {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats';
$today = current_time( 'Y-m-d' );
$table = $wpdb->prefix . 'peptide_news_daily_stats';
$today = current_time( 'Y-m-d' );
$clicks_tbl = $wpdb->prefix . 'peptide_news_clicks';
// Upsert daily click count.
$existing = $wpdb->get_row( $wpdb->prepare(
"SELECT id, click_count FROM {$table} WHERE article_id = %d AND stat_date = %s",
$article_id,
$today
$article_id, $today
) );
// Count unique IPs for today.
$unique = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(DISTINCT user_ip) FROM {$clicks_tbl} WHERE article_id = %d AND DATE(clicked_at) = %s",
$article_id,
$today
$article_id, $today
) );
if ( $existing ) {
$wpdb->update(
$table,
array(
'click_count' => $existing->click_count + 1,
'unique_visitors' => (int) $unique,
),
array( 'click_count' => $existing->click_count + 1, 'unique_visitors' => (int) $unique ),
array( 'id' => $existing->id ),
array( '%d', '%d' ),
array( '%d' )
);
} else {
$wpdb->insert(
$table,
array(
'article_id' => $article_id,
'stat_date' => $today,
'click_count' => 1,
'unique_visitors' => 1,
)
);
$wpdb->insert( $table, array(
'article_id' => $article_id,
'stat_date' => $today,
'click_count' => 1,
'unique_visitors' => 1,
) );
}
}
/**
* Get top articles by click count within a date range.
* Get the client IP address from server headers.
*
* @param string $start_date Y-m-d
* @param string $end_date Y-m-d
* @param int $limit
* @return array
*/
public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array {
global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare(
"SELECT a.id, a.title, a.source, a.source_url, a.thumbnail_url, a.categories, a.published_at,
SUM(s.click_count) AS total_clicks,
SUM(s.unique_visitors) AS total_unique
FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s
GROUP BY a.id
ORDER BY total_clicks DESC
LIMIT %d",
$start_date,
$end_date,
$limit
) );
}
/**
* Get click trends over time (daily totals).
* Checks CF-Connecting-IP, X-Forwarded-For, X-Real-IP, then REMOTE_ADDR.
*
* @param string $start_date
* @param string $end_date
* @return array
*/
public static function get_click_trends( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_daily_stats';
return $wpdb->get_results( $wpdb->prepare(
"SELECT stat_date, SUM(click_count) AS total_clicks, SUM(unique_visitors) AS total_unique
FROM {$table}
WHERE stat_date BETWEEN %s AND %s
GROUP BY stat_date
ORDER BY stat_date ASC",
$start_date,
$end_date
) );
}
/**
* Get popular topics/categories by click volume.
*
* @param string $start_date
* @param string $end_date
* @param int $limit
* @return array
*/
public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array {
global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles';
// Get articles with their click counts.
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT a.categories, a.title, SUM(s.click_count) AS clicks
FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s AND a.categories != ''
GROUP BY a.id
ORDER BY clicks DESC",
$start_date,
$end_date
) );
// Aggregate by category.
$topics = array();
foreach ( $rows as $row ) {
$cats = array_map( 'trim', explode( ',', $row->categories ) );
foreach ( $cats as $cat ) {
if ( empty( $cat ) ) {
continue;
}
$cat_lower = strtolower( $cat );
if ( ! isset( $topics[ $cat_lower ] ) ) {
$topics[ $cat_lower ] = array(
'topic' => $cat,
'total_clicks' => 0,
'article_count' => 0,
);
}
$topics[ $cat_lower ]['total_clicks'] += (int) $row->clicks;
$topics[ $cat_lower ]['article_count'] += 1;
}
}
// Sort by clicks.
usort( $topics, function ( $a, $b ) {
return $b['total_clicks'] - $a['total_clicks'];
} );
return array_slice( $topics, 0, $limit );
}
/**
* Get device breakdown stats.
*
* @param string $start_date
* @param string $end_date
* @return array
*/
public static function get_device_breakdown( string $start_date, string $end_date ): array {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_clicks';
return $wpdb->get_results( $wpdb->prepare(
"SELECT device_type, COUNT(*) AS click_count
FROM {$table}
WHERE DATE(clicked_at) BETWEEN %s AND %s
GROUP BY device_type
ORDER BY click_count DESC",
$start_date,
$end_date
) );
}
/**
* Get source performance stats.
*
* @param string $start_date
* @param string $end_date
* @return array
*/
public static function get_source_performance( string $start_date, string $end_date ): array {
global $wpdb;
$stats_table = $wpdb->prefix . 'peptide_news_daily_stats';
$articles_table = $wpdb->prefix . 'peptide_news_articles';
return $wpdb->get_results( $wpdb->prepare(
"SELECT a.source,
COUNT(DISTINCT a.id) AS article_count,
SUM(s.click_count) AS total_clicks,
ROUND(IFNULL(SUM(s.click_count) / NULLIF(COUNT(DISTINCT a.id), 0), 0), 1) AS avg_clicks_per_article
FROM {$stats_table} s
INNER JOIN {$articles_table} a ON a.id = s.article_id
WHERE s.stat_date BETWEEN %s AND %s
GROUP BY a.source
ORDER BY total_clicks DESC",
$start_date,
$end_date
) );
}
/**
* 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.
*
* 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( 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
LIMIT %d",
$start_date,
$end_date,
self::CSV_EXPORT_MAX_ROWS
), ARRAY_A );
}
/**
* Get the client IP address.
*
* @return string|null
* @return string|null Valid IP or null.
*/
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 ) {
if ( ! empty( $_SERVER[ $key ] ) ) {
$ip = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) );
$ip = trim( $ip[0] );
$ip = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) )[0] );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
return $ip;
}
@@ -325,12 +124,7 @@ class Peptide_News_Analytics {
return null;
}
/**
* Anonymize an IP address (zero out last octet for IPv4, last 80 bits for IPv6).
*
* @param string $ip
* @return string
*/
/** Anonymize IP: zero last octet (IPv4) or last 80 bits (IPv6). */
private static function anonymize_ip( string $ip ): string {
if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
return preg_replace( '/\.\d+$/', '.0', $ip );
@@ -341,12 +135,7 @@ class Peptide_News_Analytics {
return '0.0.0.0';
}
/**
* Simple device detection from user agent.
*
* @param string $ua
* @return string
*/
/** Simple device detection from user agent string. */
private static function detect_device( string $ua ): string {
$ua = strtolower( $ua );
@@ -362,4 +151,36 @@ class Peptide_News_Analytics {
return 'desktop';
}
// ── Backward-compatible proxies ─────────────────────────────────────
/** @see Peptide_News_Analytics_Reports::get_top_articles() */
public static function get_top_articles( string $start_date, string $end_date, int $limit = 20 ): array {
return Peptide_News_Analytics_Reports::get_top_articles( $start_date, $end_date, $limit );
}
/** @see Peptide_News_Analytics_Reports::get_click_trends() */
public static function get_click_trends( string $start_date, string $end_date ): array {
return Peptide_News_Analytics_Reports::get_click_trends( $start_date, $end_date );
}
/** @see Peptide_News_Analytics_Reports::get_popular_topics() */
public static function get_popular_topics( string $start_date, string $end_date, int $limit = 20 ): array {
return Peptide_News_Analytics_Reports::get_popular_topics( $start_date, $end_date, $limit );
}
/** @see Peptide_News_Analytics_Reports::get_device_breakdown() */
public static function get_device_breakdown( string $start_date, string $end_date ): array {
return Peptide_News_Analytics_Reports::get_device_breakdown( $start_date, $end_date );
}
/** @see Peptide_News_Analytics_Reports::get_source_performance() */
public static function get_source_performance( string $start_date, string $end_date ): array {
return Peptide_News_Analytics_Reports::get_source_performance( $start_date, $end_date );
}
/** @see Peptide_News_Analytics_Reports::export_clicks_csv() */
public static function export_clicks_csv( string $start_date, string $end_date ): array {
return Peptide_News_Analytics_Reports::export_clicks_csv( $start_date, $end_date );
}
}

View File

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

View File

@@ -0,0 +1,195 @@
<?php
declare( strict_types=1 );
/**
* REST API handler for the /articles endpoint.
*
* Returns paginated, cached, active articles with source-name stripping
* to clean up aggregator suffixes from titles, excerpts, and AI summaries.
*
* Called by: Peptide_News_Rest_API::register_routes() (callback for GET /articles).
* Dependencies: $wpdb, Peptide_News_Source_Resolver (for domain extraction).
*
* @since 2.6.0
* @see class-peptide-news-rest-api.php — Route registration and analytics endpoints.
*/
class Peptide_News_Rest_Articles {
/**
* GET /articles — returns paginated active articles with transient caching.
*
* Side effects: DB reads, transient cache read/write.
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response
*/
public function get_articles( $request ) {
global $wpdb;
$count = absint( min( $request->get_param( 'count' ), 100 ) );
$page = absint( max( $request->get_param( 'page' ), 1 ) );
$table = $wpdb->prefix . 'peptide_news_articles';
$cache_key = 'peptide_news_articles_' . $page . '_' . $count;
$cached_response = get_transient( $cache_key );
if ( false !== $cached_response ) {
return rest_ensure_response( $cached_response );
}
$total = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE is_active = %d", 1 ) );
$max_page = max( 1, (int) ceil( $total / $count ) );
$page = min( $page, $max_page );
$offset = ( $page - 1 ) * $count;
$articles = $wpdb->get_results( $wpdb->prepare(
"SELECT id, source, source_url, title, excerpt, ai_summary, author,
thumbnail_url, thumbnail_local, published_at, categories, tags
FROM {$table}
WHERE is_active = 1
ORDER BY published_at DESC
LIMIT %d OFFSET %d",
$count,
$offset
) );
$this->clean_source_suffixes( $articles );
$data = array(
'articles' => $articles,
'total' => (int) $total,
'page' => $page,
'per_page' => $count,
'total_pages' => ceil( $total / $count ),
);
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return rest_ensure_response( $data );
}
/**
* Strip source/publisher names from title, excerpt, and ai_summary fields.
*
* Mutates article objects in-place.
*
* @param array $articles Array of article objects from DB.
*/
private function clean_source_suffixes( array &$articles ): void {
$nbsp = "\xC2\xA0";
foreach ( $articles as $article ) {
foreach ( array( 'title', 'excerpt', 'ai_summary' ) as $field ) {
if ( empty( $article->$field ) ) {
continue;
}
$article->$field = $this->strip_source_suffix( $article->$field, $article->source, $article->source_url );
// Catch remaining nbsp-separated publisher names from Google News RSS.
$text = $article->$field;
$pos = strrpos( $text, $nbsp . $nbsp );
if ( false === $pos ) {
$pos = strrpos( $text, $nbsp );
}
if ( false !== $pos && $pos > 10 ) {
$candidate = rtrim( substr( $text, 0, $pos ) );
if ( strlen( $candidate ) > strlen( $text ) * 0.3 ) {
$article->$field = $candidate;
}
}
}
}
}
/**
* Strip the source/publisher name from the end of a string.
*
* Handles separators: " - Source", " | Source", " — Source", " – Source".
* Tries exact match against known source names, then regex fallback.
*
* @param string $text The text to clean.
* @param string $source The source name to strip.
* @param string $source_url The source URL (for domain-based publisher names).
* @return string Cleaned text.
*/
private function strip_source_suffix( string $text, string $source, string $source_url = '' ): string {
if ( empty( $text ) ) {
return $text;
}
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$nbsp = "\xC2\xA0";
$separators = array( ' - ', ' | ', ' — ', ' – ', ' // ', $nbsp . $nbsp, ' ', $nbsp );
$source_names = $this->collect_source_names( $source, $source_url );
// Try exact (case-insensitive) match against known source names.
foreach ( $source_names as $name ) {
foreach ( $separators as $sep ) {
$suffix = $sep . $name;
$suffix_len = strlen( $suffix );
if ( strlen( $text ) > $suffix_len && strcasecmp( substr( $text, -$suffix_len ), $suffix ) === 0 ) {
return rtrim( substr( $text, 0, -$suffix_len ) );
}
}
}
// Fallback 1: strip trailing " - Short Publisher Name" (1-5 words, starts uppercase).
$pattern = '/\s+[-\x{2013}\x{2014}|]\s+[A-Z][A-Za-z0-9\s.\'\-]{0,50}$/u';
$candidate = preg_replace( $pattern, '', $text );
if ( $candidate !== $text && strlen( $candidate ) > strlen( $text ) * 0.4 ) {
return rtrim( $candidate );
}
// Fallback 2: strip trailing nbsp-separated publisher names (Google News RSS).
if ( preg_match( '/^(.{10,}?)\x{00A0}{1,2}([A-Z][^.!?\x{00A0}]{0,55})$/u', $text, $m ) ) {
if ( mb_strlen( $m[1], 'UTF-8' ) > mb_strlen( $text, 'UTF-8' ) * 0.4 ) {
return rtrim( $m[1] );
}
}
return $text;
}
/**
* Build list of source names to match against for suffix stripping.
*
* @param string $source Article source field.
* @param string $source_url Article source URL.
* @return array
*/
private function collect_source_names( string $source, string $source_url ): array {
$names = array();
if ( ! empty( $source ) ) {
$names[] = $source;
}
// Cached distinct source names to avoid DB query per article.
$cached_sources = get_transient( 'peptide_news_source_names' );
if ( false === $cached_sources ) {
global $wpdb;
$table = $wpdb->prefix . 'peptide_news_articles';
$cached_sources = $wpdb->get_col( "SELECT DISTINCT source FROM {$table} WHERE source != '' ORDER BY source" );
if ( ! is_array( $cached_sources ) ) {
$cached_sources = array();
}
set_transient( 'peptide_news_source_names', $cached_sources, HOUR_IN_SECONDS );
}
$names = array_merge( $names, $cached_sources );
// Extract domain-based publisher name from URL.
if ( ! empty( $source_url ) ) {
$host = wp_parse_url( $source_url, PHP_URL_HOST );
if ( $host ) {
$host = preg_replace( '/^www\./', '', $host );
$parts = explode( '.', $host );
if ( count( $parts ) >= 2 ) {
$domain_name = $parts[ count( $parts ) - 2 ];
$names[] = str_replace( '-', ' ', $domain_name );
$names[] = $domain_name;
}
}
}
return $names;
}
}

View File

@@ -40,7 +40,9 @@ class Peptide_News {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-source-resolver.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rss-source.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics-reports.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-articles.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-client.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-llm-prompt-builder.php';
@@ -53,6 +55,7 @@ class Peptide_News {
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-assets.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-menu.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-filter-field-renderers.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-field-renderers.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-settings.php';
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'admin/class-pn-admin-log-viewer.php';