From 59d469ba0fef1dc4b9fda3a2de30c07e75c805c9 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 22:40:34 +0700 Subject: [PATCH] refactor(rest): split 435-line REST API into router + articles handler Extract article listing endpoint and source-suffix stripping into Peptide_News_Rest_Articles (195 lines). Main REST API retains route registration, analytics endpoint wrappers, AJAX delete handler, and permission/validation helpers (210 lines). Co-Authored-By: Claude Opus 4.6 --- includes/class-peptide-news-rest-api.php | 333 +++--------------- includes/class-peptide-news-rest-articles.php | 195 ++++++++++ includes/class-peptide-news.php | 1 + tests/bootstrap.php | 20 ++ tests/unit/RestApiTest.php | 3 +- 5 files changed, 272 insertions(+), 280 deletions(-) create mode 100644 includes/class-peptide-news-rest-articles.php diff --git a/includes/class-peptide-news-rest-api.php b/includes/class-peptide-news-rest-api.php index f697f4b..80d4e18 100755 --- a/includes/class-peptide-news-rest-api.php +++ b/includes/class-peptide-news-rest-api.php @@ -1,44 +1,47 @@ 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   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 ) ); } } diff --git a/includes/class-peptide-news-rest-articles.php b/includes/class-peptide-news-rest-articles.php new file mode 100644 index 0000000..de12b17 --- /dev/null +++ b/includes/class-peptide-news-rest-articles.php @@ -0,0 +1,195 @@ +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; + } +} diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 69054b6..9562719 100755 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -41,6 +41,7 @@ class Peptide_News { 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.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'; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1a31eeb..3f4007f 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -358,6 +358,26 @@ if ( ! function_exists( 'add_settings_field' ) ) { require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-reporter.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-cost-tracker.php'; +// REST API stubs. +if ( ! defined( 'HOUR_IN_SECONDS' ) ) { + define( 'HOUR_IN_SECONDS', 3600 ); +} +if ( ! function_exists( 'register_rest_route' ) ) { + function register_rest_route( $namespace, $route, $args = array(), $override = false ) { + return true; + } +} +if ( ! function_exists( 'rest_ensure_response' ) ) { + function rest_ensure_response( $response ) { + return $response; + } +} +if ( ! function_exists( 'wp_parse_url' ) ) { + function wp_parse_url( $url, $component = -1 ) { + return parse_url( $url, $component ); + } +} + // Stub WP_REST_Request for REST API tests. if ( ! class_exists( 'WP_REST_Request' ) ) { class WP_REST_Request { diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index 07d9ac1..7d5947e 100755 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -9,7 +9,8 @@ namespace PeptideNews\Tests\Unit; use PHPUnit\Framework\TestCase; -// Load the class under test. +// Load the class under test and its dependencies. +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'; class RestApiTest extends TestCase {