340 lines
11 KiB
PHP
340 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Fetches news from multiple sources and stores them in the database.
|
|
*
|
|
* Supported sources:
|
|
* - RSS / Atom feeds (Google News, PubMed, custom)
|
|
* - NewsAPI.org
|
|
*
|
|
* @since 1.0.0
|
|
*/
|
|
class Peptide_News_Fetcher {
|
|
|
|
/**
|
|
* Add custom WP-Cron schedules.
|
|
*
|
|
* @param array $schedules Existing schedules.
|
|
* @return array
|
|
*/
|
|
public function add_custom_cron_schedules( $schedules ) {
|
|
$schedules['every_fifteen_minutes'] = array(
|
|
'interval' => 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,
|
|
) );
|
|
|
|
// Run AI analysis on newly fetched articles (keywords + summary).
|
|
if ( class_exists( 'Peptide_News_LLM' ) && Peptide_News_LLM::is_enabled() ) {
|
|
$llm_batch_size = min( $stored + 5, 20 ); // Process recent unanalyzed, cap at 20.
|
|
$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 ( $fetch_log ) {
|
|
$fetch_log['ai_processed'] = $llm_processed;
|
|
update_option( 'peptide_news_last_fetch', $fetch_log );
|
|
}
|
|
}
|
|
|
|
// 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( '/<img[^>]+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 );
|
|
}
|
|
}
|
|
}
|