diff --git a/includes/class-peptide-news-fetcher.php b/includes/class-peptide-news-fetcher.php new file mode 100644 index 0000000..6306d00 --- /dev/null +++ b/includes/class-peptide-news-fetcher.php @@ -0,0 +1,326 @@ + 900, + 'display' => __( 'Every 15 Minutes', 'peptide-news' ), + ); + $schedules['every_thirty_minutes'] = array( + 'interval' => 1800, + 'display' => __( 'Every 30 Minutes', 'peptide-news' ), + ); + $schedules['every_four_hours'] = array( + 'interval' => 14400, + 'display' => __( 'Every 4 Hours', 'peptide-news' ), + ); + $schedules['every_six_hours'] = array( + 'interval' => 21600, + 'display' => __( 'Every 6 Hours', 'peptide-news' ), + ); + return $schedules; + } + + /** + * Master fetch method — called by WP-Cron. + */ + public function fetch_all_sources() { + $articles = array(); + + // Fetch from RSS feeds. + if ( get_option( 'peptide_news_rss_enabled', 1 ) ) { + $rss_articles = $this->fetch_rss_feeds(); + $articles = array_merge( $articles, $rss_articles ); + } + + // Fetch from NewsAPI. + if ( get_option( 'peptide_news_newsapi_enabled', 0 ) ) { + $api_key = get_option( 'peptide_news_newsapi_key', '' ); + if ( ! empty( $api_key ) ) { + $newsapi_articles = $this->fetch_newsapi( $api_key ); + $articles = array_merge( $articles, $newsapi_articles ); + } + } + + // Store articles (deduplication handled by hash). + $stored = 0; + foreach ( $articles as $article ) { + if ( $this->store_article( $article ) ) { + $stored++; + } + } + + // Log the fetch result. + update_option( 'peptide_news_last_fetch', array( + 'time' => current_time( 'mysql' ), + 'found' => count( $articles ), + 'new_stored' => $stored, + ) ); + + // Prune old articles beyond retention period. + $this->prune_old_articles(); + } + + /** + * Fetch articles from configured RSS feeds. + * + * @return array + */ + private function fetch_rss_feeds() { + $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 ); + $items = $feed->get_items( 0, $max_items ); + + foreach ( $items as $item ) { + $thumbnail = ''; + + // Try enclosure first. + $enclosure = $item->get_enclosure(); + if ( $enclosure && $enclosure->get_link() ) { + $thumbnail = $enclosure->get_link(); + } + + // Try media:thumbnail or media:content. + if ( empty( $thumbnail ) ) { + $thumbnail = $this->extract_image_from_content( $item->get_content() ); + } + + $pub_date = $item->get_date( 'Y-m-d H:i:s' ); + if ( empty( $pub_date ) ) { + $pub_date = current_time( 'mysql' ); + } + + $articles[] = array( + 'source' => $this->extract_domain( $feed_url ), + 'source_url' => esc_url_raw( $item->get_permalink() ), + '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' => esc_url_raw( $thumbnail ), + 'published_at' => $pub_date, + 'categories' => $this->extract_categories( $item ), + 'tags' => '', + 'language' => 'en', + ); + } + } + + return $articles; + } + + /** + * Fetch articles from NewsAPI.org. + * + * @param string $api_key + * @return array + */ + private function fetch_newsapi( $api_key ) { + $keywords = get_option( 'peptide_news_search_keywords', 'peptide research' ); + $articles = array(); + + $url = add_query_arg( array( + 'q' => urlencode( $keywords ), + 'language' => 'en', + 'sortBy' => 'publishedAt', + 'pageSize' => 50, + 'apiKey' => $api_key, + ), 'https://newsapi.org/v2/everything' ); + + $response = wp_remote_get( $url, array( + 'timeout' => 30, + 'headers' => array( 'User-Agent' => 'PeptideNewsWP/' . PEPTIDE_NEWS_VERSION ), + ) ); + + if ( is_wp_error( $response ) ) { + $this->log_error( 'NewsAPI fetch failed: ' . $response->get_error_message() ); + return $articles; + } + + $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; + } + + foreach ( $body['articles'] as $item ) { + $pub_date = isset( $item['publishedAt'] ) + ? gmdate( 'Y-m-d H:i:s', strtotime( $item['publishedAt'] ) ) + : current_time( 'mysql' ); + + $articles[] = array( + 'source' => sanitize_text_field( $item['source']['name'] ?? 'NewsAPI' ), + 'source_url' => esc_url_raw( $item['url'] ?? '' ), + 'title' => sanitize_text_field( $item['title'] ?? '' ), + 'excerpt' => wp_trim_words( sanitize_text_field( $item['description'] ?? '' ), 40 ), + 'content' => wp_kses_post( $item['content'] ?? '' ), + 'author' => sanitize_text_field( $item['author'] ?? '' ), + 'thumbnail_url' => esc_url_raw( $item['urlToImage'] ?? '' ), + 'published_at' => $pub_date, + 'categories' => '', + 'tags' => '', + 'language' => 'en', + ); + } + + return $articles; + } + + /** + * Store an article in the database. Deduplication via SHA-256 hash of URL. + * + * @param array $article + * @return bool True if new article was inserted. + */ + private function store_article( $article ) { + global $wpdb; + + $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 + ) ); + + if ( $exists ) { + return false; + } + + $result = $wpdb->insert( + $table, + array( + 'source' => $article['source'], + 'source_url' => $article['source_url'], + 'title' => $article['title'], + 'excerpt' => $article['excerpt'], + 'content' => $article['content'], + 'author' => $article['author'], + 'thumbnail_url' => $article['thumbnail_url'], + 'published_at' => $article['published_at'], + 'fetched_at' => current_time( 'mysql' ), + 'categories' => $article['categories'], + 'tags' => $article['tags'], + 'language' => $article['language'], + 'hash' => $hash, + 'is_active' => 1, + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' ) + ); + + return false !== $result; + } + + /** + * Remove articles older than the retention period. + */ + private function prune_old_articles() { + global $wpdb; + + $retention_days = (int) get_option( 'peptide_news_article_retention', 90 ); + $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( "-{$retention_days} days" ) ); + + $table = $wpdb->prefix . 'peptide_news_articles'; + $wpdb->query( $wpdb->prepare( + "UPDATE {$table} SET is_active = 0 WHERE published_at < %s", + $cutoff + ) ); + } + + /** + * Try to extract an image URL from HTML content. + * + * @param string $content + * @return string + */ + private function extract_image_from_content( $content ) { + if ( empty( $content ) ) { + return ''; + } + + if ( preg_match( '/]+src=["\']([^"\']+)["\']/', $content, $matches ) ) { + return $matches[1]; + } + + // Check for media:content or media:thumbnail. + if ( preg_match( '/url=["\']([^"\']+)["\']/', $content, $matches ) ) { + return $matches[1]; + } + + return ''; + } + + /** + * Extract categories from a SimplePie item. + * + * @param SimplePie_Item $item + * @return string Comma-separated categories. + */ + private function extract_categories( $item ) { + $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 ) ); + } + + /** + * Extract domain from a URL for source identification. + * + * @param string $url + * @return string + */ + private function extract_domain( $url ) { + $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( $message ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( '[Peptide News] ' . $message ); + } + } +}