refactor(fetcher): split 611-line fetcher into 3 focused files
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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,15 @@ declare( strict_types=1 );
|
|||||||
/**
|
/**
|
||||||
* Fetches news from multiple sources and stores them in the database.
|
* Fetches news from multiple sources and stores them in the database.
|
||||||
*
|
*
|
||||||
* Supported sources:
|
* Orchestrates the fetch cycle: RSS feeds (via RSS_Source), NewsAPI,
|
||||||
* - RSS / Atom feeds (Google News, PubMed, custom)
|
* content filtering, deduplication, AI analysis, pruning, and caching.
|
||||||
* - NewsAPI.org
|
* Triggered by WP-Cron on the configured schedule.
|
||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @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 {
|
class Peptide_News_Fetcher {
|
||||||
|
|
||||||
@@ -39,16 +43,18 @@ class Peptide_News_Fetcher {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Master fetch method — called by WP-Cron.
|
* Master fetch method — called by WP-Cron.
|
||||||
|
*
|
||||||
* Uses a transient lock to prevent concurrent executions.
|
* 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 {
|
public function fetch_all_sources(): void {
|
||||||
// Prevent overlapping fetch jobs.
|
|
||||||
$lock_key = 'peptide_news_fetch_lock';
|
$lock_key = 'peptide_news_fetch_lock';
|
||||||
if ( get_transient( $lock_key ) ) {
|
if ( get_transient( $lock_key ) ) {
|
||||||
Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' );
|
Peptide_News_Logger::warning( 'Fetch already in progress — skipping this cycle.', 'fetch' );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set_transient( $lock_key, true, 300 ); // 5-minute lock.
|
set_transient( $lock_key, true, 300 );
|
||||||
|
|
||||||
Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' );
|
Peptide_News_Logger::info( 'Fetch cycle started.', 'fetch' );
|
||||||
|
|
||||||
@@ -56,8 +62,7 @@ class Peptide_News_Fetcher {
|
|||||||
|
|
||||||
// Fetch from RSS feeds.
|
// Fetch from RSS feeds.
|
||||||
if ( get_option( 'peptide_news_rss_enabled', 1 ) ) {
|
if ( get_option( 'peptide_news_rss_enabled', 1 ) ) {
|
||||||
$rss_articles = $this->fetch_rss_feeds();
|
$articles = array_merge( $articles, Peptide_News_RSS_Source::fetch() );
|
||||||
$articles = array_merge( $articles, $rss_articles );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from NewsAPI.
|
// Fetch from NewsAPI.
|
||||||
@@ -67,8 +72,7 @@ class Peptide_News_Fetcher {
|
|||||||
? Peptide_News_Encryption::decrypt( $api_key_raw )
|
? Peptide_News_Encryption::decrypt( $api_key_raw )
|
||||||
: $api_key_raw;
|
: $api_key_raw;
|
||||||
if ( ! empty( $api_key ) ) {
|
if ( ! empty( $api_key ) ) {
|
||||||
$newsapi_articles = $this->fetch_newsapi( $api_key );
|
$articles = array_merge( $articles, $this->fetch_newsapi( $api_key ) );
|
||||||
$articles = array_merge( $articles, $newsapi_articles );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +91,10 @@ class Peptide_News_Fetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear transient caches after storing new articles.
|
|
||||||
if ( $stored > 0 ) {
|
if ( $stored > 0 ) {
|
||||||
$this->clear_article_cache();
|
$this->clear_article_cache();
|
||||||
// Invalidate REST API caches.
|
|
||||||
$this->invalidate_rest_api_cache();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the fetch result.
|
|
||||||
update_option( 'peptide_news_last_fetch', array(
|
update_option( 'peptide_news_last_fetch', array(
|
||||||
'time' => current_time( 'mysql' ),
|
'time' => current_time( 'mysql' ),
|
||||||
'found' => $pre_filter_count,
|
'found' => $pre_filter_count,
|
||||||
@@ -107,12 +107,11 @@ class Peptide_News_Fetcher {
|
|||||||
$pre_filter_count, $filtered_out, $stored
|
$pre_filter_count, $filtered_out, $stored
|
||||||
), 'fetch' );
|
), '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() ) {
|
if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) {
|
||||||
$llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) );
|
$llm_batch_size = absint( get_option( 'peptide_news_llm_max_articles', 10 ) );
|
||||||
$llm_processed = Peptide_News_LLM::process_unanalyzed( $llm_batch_size );
|
$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' );
|
$fetch_log = get_option( 'peptide_news_last_fetch' );
|
||||||
if ( is_array( $fetch_log ) ) {
|
if ( is_array( $fetch_log ) ) {
|
||||||
$fetch_log['ai_processed'] = $llm_processed;
|
$fetch_log['ai_processed'] = $llm_processed;
|
||||||
@@ -120,102 +119,20 @@ class Peptide_News_Fetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune old articles beyond retention period.
|
|
||||||
$this->prune_old_articles();
|
$this->prune_old_articles();
|
||||||
|
|
||||||
// Release the fetch lock.
|
|
||||||
delete_transient( $lock_key );
|
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.
|
* Fetch articles from NewsAPI.org.
|
||||||
*
|
*
|
||||||
* @param string $api_key
|
* Side effects: one outbound HTTP GET (30 s timeout).
|
||||||
* @return array
|
*
|
||||||
|
* @param string $api_key Decrypted NewsAPI key.
|
||||||
|
* @return array[] Article data arrays.
|
||||||
*/
|
*/
|
||||||
private function fetch_newsapi( string $api_key ): array {
|
private function fetch_newsapi( string $api_key ): array {
|
||||||
$keywords = get_option( 'peptide_news_search_keywords', 'peptide research' );
|
$keywords = get_option( 'peptide_news_search_keywords', 'peptide research' );
|
||||||
$articles = array();
|
|
||||||
|
|
||||||
$url = add_query_arg( array(
|
$url = add_query_arg( array(
|
||||||
'q' => urlencode( $keywords ),
|
'q' => urlencode( $keywords ),
|
||||||
@@ -231,17 +148,18 @@ class Peptide_News_Fetcher {
|
|||||||
) );
|
) );
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
if ( is_wp_error( $response ) ) {
|
||||||
$this->log_error( 'NewsAPI fetch failed: ' . $response->get_error_message() );
|
Peptide_News_Logger::error( 'NewsAPI fetch failed: ' . $response->get_error_message(), 'fetch' );
|
||||||
return $articles;
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||||
|
|
||||||
if ( empty( $body['articles'] ) || 'ok' !== ( $body['status'] ?? '' ) ) {
|
if ( empty( $body['articles'] ) || 'ok' !== ( $body['status'] ?? '' ) ) {
|
||||||
$this->log_error( 'NewsAPI returned no articles or error status.' );
|
Peptide_News_Logger::error( 'NewsAPI returned no articles or error status.', 'fetch' );
|
||||||
return $articles;
|
return array();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$articles = array();
|
||||||
foreach ( $body['articles'] as $item ) {
|
foreach ( $body['articles'] as $item ) {
|
||||||
$pub_date = isset( $item['publishedAt'] )
|
$pub_date = isset( $item['publishedAt'] )
|
||||||
? gmdate( 'Y-m-d H:i:s', strtotime( $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.
|
* 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.
|
* @return bool True if new article was inserted.
|
||||||
*/
|
*/
|
||||||
private function store_article( array $article ): bool {
|
private function store_article( array $article ): bool {
|
||||||
@@ -277,7 +195,6 @@ class Peptide_News_Fetcher {
|
|||||||
$table = $wpdb->prefix . 'peptide_news_articles';
|
$table = $wpdb->prefix . 'peptide_news_articles';
|
||||||
$hash = hash( 'sha256', $article['source_url'] );
|
$hash = hash( 'sha256', $article['source_url'] );
|
||||||
|
|
||||||
// Check for duplicate.
|
|
||||||
$exists = $wpdb->get_var( $wpdb->prepare(
|
$exists = $wpdb->get_var( $wpdb->prepare(
|
||||||
"SELECT id FROM {$table} WHERE hash = %s",
|
"SELECT id FROM {$table} WHERE hash = %s",
|
||||||
$hash
|
$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 {
|
private function clear_article_cache(): void {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
@@ -344,268 +261,11 @@ class Peptide_News_Fetcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate REST API response caches matching the articles pattern.
|
* Backward-compatible proxy for source backfill AJAX.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* @param SimplePie_Item $item
|
* @see Peptide_News_Source_Resolver::ajax_backfill()
|
||||||
* @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 <source> 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 <source> 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.
|
|
||||||
*/
|
*/
|
||||||
public function ajax_backfill_sources(): void {
|
public function ajax_backfill_sources(): void {
|
||||||
check_ajax_referer( 'peptide_news_admin', 'nonce' );
|
Peptide_News_Source_Resolver::ajax_backfill();
|
||||||
|
|
||||||
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 );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
118
includes/class-peptide-news-rss-source.php
Normal file
118
includes/class-peptide-news-rss-source.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
|
/**
|
||||||
|
* RSS/Atom feed source for the news fetcher.
|
||||||
|
*
|
||||||
|
* Fetches articles from user-configured RSS feed URLs using the
|
||||||
|
* WordPress SimplePie integration. Processes items in memory-friendly
|
||||||
|
* chunks and delegates source-name resolution to Source_Resolver.
|
||||||
|
*
|
||||||
|
* Called by Peptide_News_Fetcher::fetch_all_sources().
|
||||||
|
* Depends on WordPress fetch_feed() and Peptide_News_Source_Resolver.
|
||||||
|
*
|
||||||
|
* @since 2.5.0
|
||||||
|
* @see class-peptide-news-fetcher.php Orchestrator.
|
||||||
|
* @see class-peptide-news-source-resolver.php Source name resolution.
|
||||||
|
*/
|
||||||
|
class Peptide_News_RSS_Source {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch articles from all configured RSS feeds.
|
||||||
|
*
|
||||||
|
* Side effects: outbound HTTP requests via SimplePie, logger writes.
|
||||||
|
*
|
||||||
|
* @return array[] Array of article data arrays.
|
||||||
|
*/
|
||||||
|
public static function fetch(): 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 ) ) {
|
||||||
|
Peptide_News_Logger::error( 'RSS fetch failed for ' . $feed_url . ': ' . $feed->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 ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
229
includes/class-peptide-news-source-resolver.php
Normal file
229
includes/class-peptide-news-source-resolver.php
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
declare( strict_types=1 );
|
||||||
|
/**
|
||||||
|
* Resolves the real publisher source name for aggregated news articles.
|
||||||
|
*
|
||||||
|
* Aggregators like Google News wrap articles behind their own URLs, so
|
||||||
|
* the feed-level domain is useless. This class tries several strategies
|
||||||
|
* (SimplePie <source>, 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 <source> 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 <source> 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-activator.php';
|
||||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-deactivator.php';
|
||||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-encryption.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-fetcher.php';
|
||||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
|
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-analytics.php';
|
||||||
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
|
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-rest-api.php';
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ namespace PeptideNews\Tests\Unit;
|
|||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
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';
|
require_once PEPTIDE_NEWS_PLUGIN_DIR . 'includes/class-peptide-news-fetcher.php';
|
||||||
|
|
||||||
class FetcherTest extends TestCase {
|
class FetcherTest extends TestCase {
|
||||||
|
|||||||
Reference in New Issue
Block a user