From 64e715372a54d240d6b884e1f569e93bef01e176 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 22:30:50 +0700 Subject: [PATCH] refactor(fetcher): split 611-line fetcher into 3 focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract source-resolution and RSS-specific logic from the monolithic Peptide_News_Fetcher: - class-peptide-news-source-resolver.php (229 lines) — aggregator source detection, redirect resolution, title parsing, backfill - class-peptide-news-rss-source.php (118 lines) — RSS/Atom feed fetching and SimplePie item processing - class-peptide-news-fetcher.php (271 lines) — orchestrator with NewsAPI, storage, pruning, caching, and AI post-processing Also consolidated duplicate clear_article_cache/invalidate_rest_api_cache methods (they were identical) into a single clear_article_cache(). Co-Authored-By: Claude Opus 4.6 --- includes/class-peptide-news-fetcher.php | 396 ++---------------- includes/class-peptide-news-rss-source.php | 118 ++++++ .../class-peptide-news-source-resolver.php | 229 ++++++++++ includes/class-peptide-news.php | 2 + tests/unit/FetcherTest.php | 4 +- 5 files changed, 380 insertions(+), 369 deletions(-) create mode 100644 includes/class-peptide-news-rss-source.php create mode 100644 includes/class-peptide-news-source-resolver.php diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php index ab05b4f..f41a5d0 100755 --- a/includes/class-peptide-news-fetcher.php +++ b/includes/class-peptide-news-fetcher.php @@ -3,11 +3,15 @@ declare( strict_types=1 ); /** * Fetches news from multiple sources and stores them in the database. * - * Supported sources: - * - RSS / Atom feeds (Google News, PubMed, custom) - * - NewsAPI.org + * Orchestrates the fetch cycle: RSS feeds (via RSS_Source), NewsAPI, + * content filtering, deduplication, AI analysis, pruning, and caching. + * Triggered by WP-Cron on the configured schedule. * * @since 1.0.0 + * @see class-peptide-news-rss-source.php RSS feed fetcher. + * @see class-peptide-news-source-resolver.php Source name resolution. + * @see class-peptide-news-content-filter.php Ad/promo filtering. + * @see class-peptide-news-llm.php AI analysis post-fetch. */ class Peptide_News_Fetcher { @@ -39,16 +43,18 @@ class Peptide_News_Fetcher { /** * Master fetch method — called by WP-Cron. + * * Uses a transient lock to prevent concurrent executions. + * Side effects: DB writes, HTTP requests, transient operations, + * option updates, delegates to LLM processing. */ public function fetch_all_sources(): void { - // Prevent overlapping fetch jobs. $lock_key = 'peptide_news_fetch_lock'; if ( get_transient( $lock_key ) ) { Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' ); return; } - set_transient( $lock_key, true, 300 ); // 5-minute lock. + set_transient( $lock_key, true, 300 ); Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' ); @@ -56,8 +62,7 @@ class Peptide_News_Fetcher { // Fetch from RSS feeds. if ( get_option( 'peptide_news_rss_enabled', 1 ) ) { - $rss_articles = $this->fetch_rss_feeds(); - $articles = array_merge( $articles, $rss_articles ); + $articles = array_merge( $articles, Peptide_News_RSS_Source::fetch() ); } // Fetch from NewsAPI. @@ -67,8 +72,7 @@ class Peptide_News_Fetcher { ? 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 ); + $articles = array_merge( $articles, $this->fetch_newsapi( $api_key ) ); } } @@ -87,14 +91,10 @@ class Peptide_News_Fetcher { } } - // Clear transient caches after storing new articles. if ( $stored > 0 ) { $this->clear_article_cache(); - // Invalidate REST API caches. - $this->invalidate_rest_api_cache(); } - // Log the fetch result. update_option( 'peptide_news_last_fetch', array( 'time' => current_time( 'mysql' ), 'found' => $pre_filter_count, @@ -107,12 +107,11 @@ class Peptide_News_Fetcher { $pre_filter_count, $filtered_out, $stored ), 'fetch' ); - // Run AI analysis on newly fetched articles (keywords + summary). + // Run AI analysis on newly fetched articles. if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) { $llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) ); $llm_processed = Peptide_News_LLM::process_unanalyzed( $llm_batch_size ); - // Append LLM stats to the fetch log. $fetch_log = get_option( 'peptide_news_last_fetch' ); if ( is_array( $fetch_log ) ) { $fetch_log['ai_processed'] = $llm_processed; @@ -120,102 +119,20 @@ class Peptide_News_Fetcher { } } - // Prune old articles beyond retention period. $this->prune_old_articles(); - - // Release the fetch lock. delete_transient( $lock_key ); } - /** - * Fetch articles from configured RSS feeds. - * - * @return array - */ - private function fetch_rss_feeds(): array { - $feeds_raw = get_option( 'peptide_news_rss_feeds', '' ); - $feeds = array_filter( array_map( 'trim', explode( "\n", $feeds_raw ) ) ); - $articles = array(); - - if ( ! function_exists( 'fetch_feed' ) ) { - require_once ABSPATH . WPINC . '/feed.php'; - } - - foreach ( $feeds as $feed_url ) { - $feed = fetch_feed( esc_url_raw( $feed_url ) ); - - if ( is_wp_error( $feed ) ) { - $this->log_error( 'RSS fetch failed for ' . $feed_url . ': ' . $feed->get_error_message() ); - continue; - } - - $max_items = $feed->get_item_quantity( 50 ); - - // Process items in batches of 10 to manage memory usage. - $chunk_size = 10; - for ( $offset = 0; $offset < $max_items; $offset += $chunk_size ) { - $items = $feed->get_items( $offset, $chunk_size ); - - if ( empty( $items ) ) { - break; - } - - foreach ( $items as $item ) { - $article = $this->process_rss_item( $item, $feed_url ); - if ( ! empty( $article ) ) { - $articles[] = $article; - } - } - } - } - - return $articles; - } - - /** - * Process a single RSS item into an article array. - * - * @param SimplePie_Item $item - * @param string $feed_url - * @return array Article data or empty array if processing failed. - */ - private function process_rss_item( $item, string $feed_url ): array { - $pub_date = $item->get_date( 'Y-m-d H:i:s' ); - if ( empty( $pub_date ) ) { - $pub_date = current_time( 'mysql' ); - } - - $article_url = esc_url_raw( $item->get_permalink() ); - if ( empty( $article_url ) ) { - return array(); - } - - $source_name = $this->resolve_article_source( $item, $feed_url, $article_url ); - - return array( - 'source' => $source_name, - 'source_url' => $article_url, - 'title' => sanitize_text_field( $item->get_title() ), - 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), - 'content' => wp_kses_post( $item->get_content() ), - 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), - 'thumbnail_url' => '', - 'published_at' => $pub_date, - 'categories' => $this->extract_categories( $item ), - 'tags' => '', - 'language' => 'en', - ); - } - /** * Fetch articles from NewsAPI.org. * - * @param string $api_key - * @return array + * Side effects: one outbound HTTP GET (30 s timeout). + * + * @param string $api_key Decrypted NewsAPI key. + * @return array[] Article data arrays. */ private function fetch_newsapi( string $api_key ): array { $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' ); - $articles = array(); $url = add_query_arg( array( 'q' => urlencode( $keywords ), @@ -231,17 +148,18 @@ class Peptide_News_Fetcher { ) ); if ( is_wp_error( $response ) ) { - $this->log_error( 'NewsAPI fetch failed: ' . $response->get_error_message() ); - return $articles; + Peptide_News_Logger::error( 'NewsAPI fetch failed: ' . $response->get_error_message(), 'fetch' ); + return array(); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['articles'] ) || 'ok' !== ( $body['status'] ?? '' ) ) { - $this->log_error( 'NewsAPI returned no articles or error status.' ); - return $articles; + Peptide_News_Logger::error( 'NewsAPI returned no articles or error status.', 'fetch' ); + return array(); } + $articles = array(); foreach ( $body['articles'] as $item ) { $pub_date = isset( $item['publishedAt'] ) ? gmdate( 'Y-m-d H:i:s', strtotime( $item['publishedAt'] ) ) @@ -268,7 +186,7 @@ class Peptide_News_Fetcher { /** * Store an article in the database. Deduplication via SHA-256 hash of URL. * - * @param array $article + * @param array $article Article data array. * @return bool True if new article was inserted. */ private function store_article( array $article ): bool { @@ -277,7 +195,6 @@ class Peptide_News_Fetcher { $table = $wpdb->prefix . 'peptide_news_articles'; $hash = hash( 'sha256', $article['source_url'] ); - // Check for duplicate. $exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE hash = %s", $hash @@ -328,7 +245,7 @@ class Peptide_News_Fetcher { } /** - * Clear all article transient caches. + * Clear all article transient caches (front-end and REST API). */ private function clear_article_cache(): void { global $wpdb; @@ -344,268 +261,11 @@ class Peptide_News_Fetcher { } /** - * Invalidate REST API response caches matching the articles pattern. - */ - private function invalidate_rest_api_cache(): void { - global $wpdb; - $wpdb->query( - $wpdb->prepare( - "DELETE FROM {$wpdb->options} - WHERE option_name LIKE %s - OR option_name LIKE %s", - $wpdb->esc_like( '_transient_peptide_news_articles_' ) . '%', - $wpdb->esc_like( '_transient_timeout_peptide_news_articles_' ) . '%' - ) - ); - } - - /** - * Extract categories from a SimplePie item. + * Backward-compatible proxy for source backfill AJAX. * - * @param SimplePie_Item $item - * @return string Comma-separated categories. - */ - private function extract_categories( $item ): string { - $categories = $item->get_categories(); - if ( empty( $categories ) ) { - return ''; - } - - $names = array(); - foreach ( $categories as $cat ) { - $names[] = sanitize_text_field( $cat->get_label() ); - } - - return implode( ', ', array_filter( $names ) ); - } - - /** - * Resolve the real source name for an RSS feed item. - * - * Google News RSS wraps articles from other publishers behind - * news.google.com URLs, so the feed-level domain is useless. - * This method tries several strategies in priority order: - * - * 1. SimplePie element (Google News RSS includes this). - * 2. Title suffix — Google News formats titles as "Headline - Source". - * 3. Resolve the redirect URL to get the actual publisher domain. - * 4. Fall back to the article permalink domain. - * 5. Last resort: feed URL domain. - * - * @param SimplePie_Item $item The RSS item. - * @param string $feed_url The feed URL. - * @param string $article_url The article permalink. - * @return string Source name or domain. - */ - private function resolve_article_source( $item, string $feed_url, string $article_url ): string { - $feed_domain = $this->extract_domain( $feed_url ); - $is_aggregator = in_array( - $feed_domain, - array( 'news.google.com', 'news.yahoo.com', 'msn.com', 'feedly.com' ), - true - ); - - // Strategy 1: SimplePie element. - $source_obj = $item->get_source(); - if ( $source_obj ) { - $source_title = $source_obj->get_title(); - if ( ! empty( $source_title ) ) { - return sanitize_text_field( $source_title ); - } - $source_link = $source_obj->get_link(); - if ( ! empty( $source_link ) ) { - $domain = $this->extract_domain( $source_link ); - if ( 'unknown' !== $domain ) { - return $domain; - } - } - } - - // Strategy 2: Parse "Headline - Source" from title (common in aggregator feeds). - if ( $is_aggregator ) { - $title = $item->get_title(); - if ( ! empty( $title ) && preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $title, $matches ) ) { - $candidate = trim( $matches[1] ); - if ( mb_strlen( $candidate ) <= 60 && mb_strlen( $candidate ) >= 2 ) { - return sanitize_text_field( $candidate ); - } - } - } - - // Strategy 3: Resolve Google News redirect URL to actual destination. - if ( $is_aggregator ) { - $resolved = $this->resolve_redirect_url( $article_url ); - if ( $resolved && $resolved !== $article_url ) { - $domain = $this->extract_domain( $resolved ); - if ( 'unknown' !== $domain && $domain !== $feed_domain ) { - return $domain; - } - } - } - - // Strategy 4: Use the article permalink domain. - $article_domain = $this->extract_domain( $article_url ); - if ( 'unknown' !== $article_domain && $article_domain !== $feed_domain ) { - return $article_domain; - } - - // Strategy 5: Fall back to the feed domain. - return $feed_domain; - } - - /** - * Resolve a redirect URL to its final destination without downloading the body. - * - * @param string $url The URL to resolve. - * @return string|false The final URL or false on failure. - */ - private function resolve_redirect_url( string $url ) { - if ( empty( $url ) ) { - return false; - } - - $response = wp_remote_head( $url, array( - 'timeout' => 8, - 'redirection' => 5, - 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', - ) ); - - if ( is_wp_error( $response ) ) { - return false; - } - - $final_url = wp_remote_retrieve_header( $response, 'location' ); - - if ( empty( $final_url ) && isset( $response['http_response'] ) ) { - $http_response = $response['http_response']; - if ( method_exists( $http_response, 'get_response_object' ) ) { - $raw = $http_response->get_response_object(); - if ( isset( $raw->url ) ) { - $final_url = $raw->url; - } - } - } - - // If we have a final URL, validate that it's not a cross-domain redirect - if ( ! empty( $final_url ) ) { - $original_host = wp_parse_url( $url, PHP_URL_HOST ); - $final_host = wp_parse_url( $final_url, PHP_URL_HOST ); - - // Reject cross-domain redirects - if ( $original_host !== $final_host ) { - return false; - } - - return $final_url; - } - - return false; - } - - /** - * Backfill source names for existing articles that show an aggregator domain. - * - * Parses the " - Source" suffix from stored titles to update the source column. - * - * @return int Number of articles updated. - */ - public function backfill_article_sources(): int { - global $wpdb; - - $table = $wpdb->prefix . 'peptide_news_articles'; - $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); - $placeholders = implode( ', ', array_fill( 0, count( $aggregators ), '%s' ) ); - - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared - $articles = $wpdb->get_results( $wpdb->prepare( - "SELECT id, title, source, source_url - FROM {$table} - WHERE source IN ({$placeholders}) - AND is_active = 1", - ...$aggregators - ) ); - - if ( empty( $articles ) ) { - return 0; - } - - $updated = 0; - - foreach ( $articles as $article ) { - $new_source = ''; - - // Try parsing "Headline - Source" from title. - if ( preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $article->title, $matches ) ) { - $candidate = trim( $matches[1] ); - if ( mb_strlen( $candidate ) <= 60 && mb_strlen( $candidate ) >= 2 ) { - $new_source = sanitize_text_field( $candidate ); - } - } - - // Fall back to resolving the redirect URL. - if ( empty( $new_source ) ) { - $resolved = $this->resolve_redirect_url( $article->source_url ); - if ( $resolved && $resolved !== $article->source_url ) { - $domain = $this->extract_domain( $resolved ); - if ( 'unknown' !== $domain && ! in_array( $domain, $aggregators, true ) ) { - $new_source = $domain; - } - } - usleep( 300000 ); // 0.3s throttle for HTTP requests. - } - - if ( ! empty( $new_source ) && $new_source !== $article->source ) { - $wpdb->update( - $table, - array( 'source' => $new_source ), - array( 'id' => $article->id ), - array( '%s' ), - array( '%d' ) - ); - $updated++; - } - } - - if ( $updated > 0 ) { - $this->clear_article_cache(); - } - - return $updated; - } - - /** - * AJAX handler for the backfill action. + * @see Peptide_News_Source_Resolver::ajax_backfill() */ public function ajax_backfill_sources(): void { - check_ajax_referer( 'peptide_news_admin', 'nonce' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( 'Unauthorized', 403 ); - } - - $updated = $this->backfill_article_sources(); - wp_send_json_success( array( 'updated' => $updated ) ); - } - - /** - * Extract domain from a URL for source identification. - * - * @param string $url - * @return string - */ - private function extract_domain( string $url ): string { - $host = wp_parse_url( $url, PHP_URL_HOST ); - return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown'; - } - - /** - * Log an error to the WordPress debug log. - * - * @param string $message - */ - private function log_error( string $message ): void { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( '[Peptide News] ' . $message ); - } + Peptide_News_Source_Resolver::ajax_backfill(); } } diff --git a/includes/class-peptide-news-rss-source.php b/includes/class-peptide-news-rss-source.php new file mode 100644 index 0000000..c178325 --- /dev/null +++ b/includes/class-peptide-news-rss-source.php @@ -0,0 +1,118 @@ +get_error_message(), 'fetch' ); + continue; + } + + $max_items = $feed->get_item_quantity( 50 ); + $chunk_size = 10; + + for ( $offset = 0; $offset < $max_items; $offset += $chunk_size ) { + $items = $feed->get_items( $offset, $chunk_size ); + if ( empty( $items ) ) { + break; + } + + foreach ( $items as $item ) { + $article = self::process_item( $item, $feed_url ); + if ( ! empty( $article ) ) { + $articles[] = $article; + } + } + } + } + + return $articles; + } + + /** + * Process a single RSS/Atom item into an article array. + * + * @param \SimplePie_Item $item The feed item. + * @param string $feed_url The originating feed URL. + * @return array Article data or empty array on failure. + */ + private static function process_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' ); + } + + $article_url = esc_url_raw( $item->get_permalink() ); + if ( empty( $article_url ) ) { + return array(); + } + + $source_name = Peptide_News_Source_Resolver::resolve( $item, $feed_url, $article_url ); + + return array( + 'source' => $source_name, + 'source_url' => $article_url, + 'title' => sanitize_text_field( $item->get_title() ), + 'excerpt' => wp_trim_words( wp_strip_all_tags( $item->get_description() ), 40 ), + 'content' => wp_kses_post( $item->get_content() ), + 'author' => sanitize_text_field( $item->get_author() ? $item->get_author()->get_name() : '' ), + 'thumbnail_url' => '', + 'published_at' => $pub_date, + 'categories' => self::extract_categories( $item ), + 'tags' => '', + 'language' => 'en', + ); + } + + /** + * Extract category labels from a SimplePie item. + * + * @param \SimplePie_Item $item + * @return string Comma-separated categories. + */ + private static function extract_categories( $item ): string { + $categories = $item->get_categories(); + if ( empty( $categories ) ) { + return ''; + } + + $names = array(); + foreach ( $categories as $cat ) { + $names[] = sanitize_text_field( $cat->get_label() ); + } + + return implode( ', ', array_filter( $names ) ); + } +} diff --git a/includes/class-peptide-news-source-resolver.php b/includes/class-peptide-news-source-resolver.php new file mode 100644 index 0000000..2a4d1c2 --- /dev/null +++ b/includes/class-peptide-news-source-resolver.php @@ -0,0 +1,229 @@ +, title suffix parsing, redirect resolution) to + * find the actual publisher. Also provides a backfill routine for + * existing articles stored with aggregator domains. + * + * Called by Peptide_News_RSS_Source during item processing and by the + * admin "Backfill Sources" AJAX action. + * + * @since 2.5.0 + * @see class-peptide-news-rss-source.php Consumes resolve_article_source(). + * @see class-peptide-news-fetcher.php Orchestrator that triggers fetch cycles. + */ +class Peptide_News_Source_Resolver { + + /** @var string[] Domains that aggregate content from other publishers. */ + const AGGREGATOR_DOMAINS = array( 'news.google.com', 'news.yahoo.com', 'msn.com', 'feedly.com' ); + + /** + * Resolve the real source name for an RSS feed item. + * + * Tries strategies in priority order: SimplePie element, + * title suffix parsing, redirect URL resolution, article domain, + * then feed domain as last resort. + * + * @param \SimplePie_Item $item The RSS item. + * @param string $feed_url The feed URL. + * @param string $article_url The article permalink. + * @return string Source name or domain. + */ + public static function resolve( $item, string $feed_url, string $article_url ): string { + $feed_domain = self::extract_domain( $feed_url ); + $is_aggregator = in_array( $feed_domain, self::AGGREGATOR_DOMAINS, true ); + + // Strategy 1: SimplePie element. + $source_obj = $item->get_source(); + if ( $source_obj ) { + $source_title = $source_obj->get_title(); + if ( ! empty( $source_title ) ) { + return sanitize_text_field( $source_title ); + } + $source_link = $source_obj->get_link(); + if ( ! empty( $source_link ) ) { + $domain = self::extract_domain( $source_link ); + if ( 'unknown' !== $domain ) { + return $domain; + } + } + } + + // Strategy 2: Parse "Headline - Source" from title. + if ( $is_aggregator ) { + $parsed = self::parse_source_from_title( $item->get_title() ); + if ( $parsed ) { + return $parsed; + } + } + + // Strategy 3: Resolve aggregator redirect URL to actual destination. + if ( $is_aggregator ) { + $resolved = self::resolve_redirect_url( $article_url ); + if ( $resolved && $resolved !== $article_url ) { + $domain = self::extract_domain( $resolved ); + if ( 'unknown' !== $domain && $domain !== $feed_domain ) { + return $domain; + } + } + } + + // Strategy 4: Article permalink domain. + $article_domain = self::extract_domain( $article_url ); + if ( 'unknown' !== $article_domain && $article_domain !== $feed_domain ) { + return $article_domain; + } + + // Strategy 5: Feed domain. + return $feed_domain; + } + + /** + * Backfill source names for existing articles stored with aggregator domains. + * + * Side effects: DB reads + updates, HTTP HEAD requests (throttled 0.3 s each), + * clears article caches if any rows updated. + * + * @return int Number of articles updated. + */ + public static function backfill(): int { + global $wpdb; + + $table = $wpdb->prefix . 'peptide_news_articles'; + $aggregators = array( 'news.google.com', 'news.yahoo.com', 'msn.com' ); + $placeholders = implode( ', ', array_fill( 0, count( $aggregators ), '%s' ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $articles = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title, source, source_url FROM {$table} WHERE source IN ({$placeholders}) AND is_active = 1", + ...$aggregators + ) ); + + if ( empty( $articles ) ) { + return 0; + } + + $updated = 0; + + foreach ( $articles as $article ) { + $new_source = self::parse_source_from_title( $article->title ); + + // Fall back to resolving the redirect URL. + if ( empty( $new_source ) ) { + $resolved = self::resolve_redirect_url( $article->source_url ); + if ( $resolved && $resolved !== $article->source_url ) { + $domain = self::extract_domain( $resolved ); + if ( 'unknown' !== $domain && ! in_array( $domain, $aggregators, true ) ) { + $new_source = $domain; + } + } + usleep( 300000 ); // 0.3s throttle for HTTP requests. + } + + if ( ! empty( $new_source ) && $new_source !== $article->source ) { + $wpdb->update( $table, array( 'source' => $new_source ), array( 'id' => $article->id ), array( '%s' ), array( '%d' ) ); + $updated++; + } + } + + return $updated; + } + + /** + * AJAX handler for the backfill action. + * + * Side effects: nonce + capability check, delegates to backfill(), + * clears article cache on success, sends JSON response. + */ + public static function ajax_backfill(): void { + check_ajax_referer( 'peptide_news_admin', 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized', 403 ); + } + + $updated = self::backfill(); + wp_send_json_success( array( 'updated' => $updated ) ); + } + + /** + * Parse "Headline - Source" pattern from a title string. + * + * @param string|null $title + * @return string|null Source name or null if pattern not found. + */ + private static function parse_source_from_title( ?string $title ): ?string { + if ( empty( $title ) ) { + return null; + } + if ( preg_match( '/\s[-\x{2013}\x{2014}]\s([^-\x{2013}\x{2014}]+)$/u', $title, $matches ) ) { + $candidate = trim( $matches[1] ); + if ( mb_strlen( $candidate ) >= 2 && mb_strlen( $candidate ) <= 60 ) { + return sanitize_text_field( $candidate ); + } + } + return null; + } + + /** + * Resolve a redirect URL to its final destination via HEAD request. + * + * Side effects: one outbound HTTP HEAD (8 s timeout, up to 5 redirects). + * + * @param string $url The URL to resolve. + * @return string|false The final URL or false on failure. + */ + private static function resolve_redirect_url( string $url ) { + if ( empty( $url ) ) { + return false; + } + + $response = wp_remote_head( $url, array( + 'timeout' => 8, + 'redirection' => 5, + 'user-agent' => 'Mozilla/5.0 (compatible; PeptideNewsBot/1.0)', + ) ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $final_url = wp_remote_retrieve_header( $response, 'location' ); + + if ( empty( $final_url ) && isset( $response['http_response'] ) ) { + $http_response = $response['http_response']; + if ( method_exists( $http_response, 'get_response_object' ) ) { + $raw = $http_response->get_response_object(); + if ( isset( $raw->url ) ) { + $final_url = $raw->url; + } + } + } + + if ( ! empty( $final_url ) ) { + // Reject cross-domain redirects. + $original_host = wp_parse_url( $url, PHP_URL_HOST ); + $final_host = wp_parse_url( $final_url, PHP_URL_HOST ); + if ( $original_host !== $final_host ) { + return false; + } + return $final_url; + } + + return false; + } + + /** + * Extract domain from a URL, stripping "www." prefix. + * + * @param string $url + * @return string Domain or 'unknown'. + */ + public static function extract_domain( string $url ): string { + $host = wp_parse_url( $url, PHP_URL_HOST ); + return $host ? preg_replace( '/^www\./', '', $host ) : 'unknown'; + } +} diff --git a/includes/class-peptide-news.php b/includes/class-peptide-news.php index 5b4ad37..4796a7a 100755 --- a/includes/class-peptide-news.php +++ b/includes/class-peptide-news.php @@ -37,6 +37,8 @@ class Peptide_News { 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-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.php'; require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php'; diff --git a/tests/unit/FetcherTest.php b/tests/unit/FetcherTest.php index 2bd8e11..d61b4f5 100755 --- a/tests/unit/FetcherTest.php +++ b/tests/unit/FetcherTest.php @@ -9,7 +9,9 @@ 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-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'; class FetcherTest extends TestCase {