diff --git a/includes/class-peptide-news-llm.php b/includes/class-peptide-news-llm.php index 8503685..89bfba7 100644 --- a/includes/class-peptide-news-llm.php +++ b/includes/class-peptide-news-llm.php @@ -370,15 +370,271 @@ class Peptide_News_LLM { } } + /** + * Generate AI thumbnails for articles that still have no image + * after OG scraping. Uses an image generation model via OpenRouter. + * + * @param int $batch_size Max articles to process per run. + * @return int Number of thumbnails generated. + */ + public static function generate_missing_thumbnails( $batch_size = 10 ) { + if ( ! self::is_enabled() ) { + return 0; + } + + $image_model = get_option( 'peptide_news_llm_image_model', '' ); + if ( empty( $image_model ) ) { + return 0; + } + + global $wpdb; + $table = $wpdb->prefix . 'peptide_news_articles'; + + // Find active articles with no thumbnail (neither URL nor local). + $articles = $wpdb->get_results( $wpdb->prepare( + "SELECT id, title, excerpt, ai_summary, tags, categories + FROM {$table} + WHERE is_active = 1 + AND ( thumbnail_url = '' OR thumbnail_url IS NULL ) + AND ( thumbnail_local = '' OR thumbnail_local IS NULL ) + ORDER BY fetched_at DESC + LIMIT %d", + $batch_size + ) ); + + if ( empty( $articles ) ) { + return 0; + } + + $api_key = get_option( 'peptide_news_openrouter_api_key', '' ); + $generated = 0; + + foreach ( $articles as $article ) { + $image_data = self::generate_article_thumbnail( $api_key, $image_model, $article ); + + if ( $image_data ) { + // Save the image locally. + $local_path = self::save_generated_image( $image_data, $article->id ); + + if ( $local_path ) { + $wpdb->update( + $table, + array( 'thumbnail_local' => $local_path ), + array( 'id' => $article->id ), + array( '%s' ), + array( '%d' ) + ); + $generated++; + } + } + + // Respect rate limits. + usleep( 1000000 ); // 1 second between generations. + } + + if ( $generated > 0 ) { + self::clear_article_cache(); + } + + return $generated; + } + + /** + * Generate a thumbnail image for a single article via OpenRouter. + * + * @param string $api_key + * @param string $model Image generation model ID. + * @param object $article Database row. + * @return string|false Base64-encoded image data, or false. + */ + private static function generate_article_thumbnail( $api_key, $model, $article ) { + $prompt = self::build_image_prompt( $article ); + + $body = array( + 'model' => $model, + 'messages' => array( + array( + 'role' => 'user', + 'content' => $prompt, + ), + ), + ); + + $response = wp_remote_post( self::API_URL, array( + 'timeout' => 60, + 'headers' => array( + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + 'HTTP-Referer' => home_url(), + 'X-Title' => 'Peptide News Aggregator', + ), + 'body' => wp_json_encode( $body ), + ) ); + + if ( is_wp_error( $response ) ) { + self::log_error( 'Image generation failed for article ' . $article->id . ': ' . $response->get_error_message() ); + return false; + } + + $status = wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( $status < 200 || $status >= 300 ) { + $error = isset( $data['error']['message'] ) ? $data['error']['message'] : 'HTTP ' . $status; + self::log_error( 'Image generation HTTP error for article ' . $article->id . ': ' . $error ); + return false; + } + + // Extract the image data from the response. + // OpenRouter image models may return base64 data in various formats. + if ( ! empty( $data['choices'][0]['message']['content'] ) ) { + $content = $data['choices'][0]['message']['content']; + + // If content is an array (multimodal), look for image parts. + if ( is_array( $content ) ) { + foreach ( $content as $part ) { + if ( isset( $part['type'] ) && 'image_url' === $part['type'] + && isset( $part['image_url'] ) && is_array( $part['image_url'] ) + && isset( $part['image_url']['url'] ) && is_string( $part['image_url']['url'] ) ) { + $url = $part['image_url']['url']; + if ( strpos( $url, 'data:image' ) === 0 ) { + // Extract base64 from data URL. + $base64 = preg_replace( '/^data:image\/\w+;base64,/', '', $url ); + $decoded = base64_decode( $base64, true ); + if ( false === $decoded ) { + self::log_error( 'Invalid base64 image data for article.' ); + return false; + } + return $decoded; + } + } + } + } + + // If it's a URL string, download it (with SSRF protection). + if ( is_string( $content ) && preg_match( '/^https?:\/\//', $content ) ) { + $content_url = trim( $content ); + if ( ! wp_http_validate_url( $content_url ) ) { + self::log_error( 'Invalid image URL (SSRF protection) for article ' . $article->id ); + return false; + } + $img_response = wp_remote_get( $content_url, array( + 'timeout' => 30, + 'limit_response_size' => 5 * 1024 * 1024, + ) ); + if ( ! is_wp_error( $img_response ) ) { + return wp_remote_retrieve_body( $img_response ); + } + } + } + + // Check for data array format (DALL-E style). + if ( ! empty( $data['data'][0]['b64_json'] ) ) { + $decoded = base64_decode( $data['data'][0]['b64_json'], true ); + if ( false === $decoded ) { + self::log_error( 'Invalid base64 in DALL-E response for article ' . $article->id ); + return false; + } + return $decoded; + } + + if ( ! empty( $data['data'][0]['url'] ) ) { + $dalle_url = $data['data'][0]['url']; + if ( wp_http_validate_url( $dalle_url ) ) { + $img_response = wp_remote_get( $dalle_url, array( + 'timeout' => 30, + 'limit_response_size' => 5 * 1024 * 1024, + ) ); + if ( ! is_wp_error( $img_response ) ) { + return wp_remote_retrieve_body( $img_response ); + } + } else { + self::log_error( 'Invalid DALL-E image URL (SSRF protection) for article ' . $article->id ); + } + } + + self::log_error( 'Could not extract image data from response for article ' . $article->id ); + return false; + } + + /** + * Build the prompt for AI image generation. + * + * @param object $article + * @return string + */ + private static function build_image_prompt( $article ) { + $summary = ! empty( $article->ai_summary ) ? $article->ai_summary : $article->excerpt; + $tags = ! empty( $article->tags ) ? $article->tags : ( $article->categories ?? '' ); + + return "Generate a professional, science-themed thumbnail image for a peptide research news article. " + . "The image should be modern, clean, and suitable for a news website card layout (16:9 aspect ratio). " + . "Use a scientific aesthetic: molecular structures, laboratory elements, or abstract biotech visuals. " + . "Do NOT include any text or words in the image.\n\n" + . "Article title: " . $article->title . "\n" + . ( ! empty( $summary ) ? "Summary: " . mb_substr( $summary, 0, 200 ) . "\n" : '' ) + . ( ! empty( $tags ) ? "Keywords: " . $tags . "\n" : '' ); + } + + /** + * Save a generated image to the WP uploads directory. + * + * @param string $image_data Raw image binary data. + * @param int $article_id + * @return string|false Relative path from uploads base, or false. + */ + private static function save_generated_image( $image_data, $article_id ) { + if ( empty( $image_data ) || strlen( $image_data ) < 1000 ) { + return false; + } + + $upload_dir = wp_upload_dir(); + $target_dir = $upload_dir['basedir'] . '/peptide-news-thumbs'; + + if ( ! file_exists( $target_dir ) ) { + wp_mkdir_p( $target_dir ); + } + + // Detect image type from data. + $finfo = new finfo( FILEINFO_MIME_TYPE ); + $mime = $finfo->buffer( $image_data ); + $ext_map = array( + 'image/png' => 'png', + 'image/jpeg' => 'jpg', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + ); + $extension = isset( $ext_map[ $mime ] ) ? $ext_map[ $mime ] : 'png'; + + $filename = 'pn-ai-' . $article_id . '.' . $extension; + $filepath = $target_dir . '/' . $filename; + + $result = file_put_contents( $filepath, $image_data ); + + if ( false === $result ) { + self::log_error( 'Failed to save generated thumbnail: ' . $filepath ); + return false; + } + + // Set secure file permissions. + chmod( $filepath, 0644 ); + + return 'peptide-news-thumbs/' . $filename; + } + /** * Clear all article transient caches. */ private static function clear_article_cache() { global $wpdb; $wpdb->query( - "DELETE FROM {$wpdb->options} - WHERE option_name LIKE '_transient_peptide_news_articles_%' - OR option_name LIKE '_transient_timeout_peptide_news_articles_%'" + $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_' ) . '%' + ) ); }