options = $options; $this->logger = $logger; $this->api_handler = $api_handler; $this->image_handler = $image_handler; $this->domain_timestamps = get_option( AINP_DOMAIN_TIMESTAMPS_OPTION, array() ); } /** * Updates the internal options array. * * @param array $options New plugin options. */ public function set_options( $options ) { $this->options = $options; } /** * Processes a single source configuration. * Checks domain cooldown. * * @param array $source_config Configuration array for the source. * @param string|int $search_depth How many items to fetch ('all', '1', or number). * @param bool $is_manual_url True if triggered via 'Fetch Specific URL'. * @return array A summary array: ['created' => int, 'skipped' => int, 'errors' => int]. */ public function process_single_source( $source_config, $search_depth = 'all', $is_manual_url = false ) { $domain = wp_parse_url( $source_config['url'], PHP_URL_HOST ); if ( ! $is_manual_url && $this->is_domain_on_cooldown( $domain ) ) { /* translators: %s: Domain name */ $this->logger->add_log_entry( 'info', sprintf( __( 'Source %s skipped due to respect interval cooldown.', 'ainp-ai-native-publisher' ), esc_html( $domain ) ) ); return array( 'created' => 0, 'skipped' => 0, 'errors' => 0 ); } // Scraper support removed for free version compliance. // Always default to RSS/Feed processing. return $this->process_single_feed_source( $source_config['url'], $search_depth, $is_manual_url ); } /** * Processes a single RSS/Atom feed source URL. * * @param string $feed_url The URL of the feed. * @param string|int $search_depth Max number of items to process. * @param bool $is_manual_url Indicates if it's a manual fetch. * @return array A summary array: ['created' => int, 'skipped' => int, 'errors' => int]. */ private function process_single_feed_source( $feed_url, $search_depth = 'all', $is_manual_url = false ) { $summary = array( 'created' => 0, 'skipped' => 0, 'errors' => 0 ); /* translators: 1: Feed URL, 2: Search depth */ $this->logger->add_log_entry( 'info', __( 'Starting RSS feed processing.', 'ainp-ai-native-publisher' ), sprintf( 'URL: %1$s | Depth: %2$s', esc_url( $feed_url ), esc_html( $search_depth ) ) ); $body = false; $cache_key = 'ainp_feed_' . md5( $feed_url ); if ( ! empty( $this->options['enable_feed_cache'] ) ) { $body = get_transient( $cache_key ); if ( false !== $body ) { $this->logger->add_log_entry( 'info', __( 'Feed content loaded from cache.', 'ainp-ai-native-publisher' ), 'URL: ' . esc_url( $feed_url ) ); } } if ( false === $body ) { $max_retries = 3; for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) { $response = wp_remote_get( $feed_url, array( 'timeout' => 30 ) ); if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) { $body = wp_remote_retrieve_body( $response ); if ( ! empty( $body ) ) { if ( ! empty( $this->options['enable_feed_cache'] ) ) { $cache_duration = ! empty( $this->options['feed_cache_duration'] ) ? absint( $this->options['feed_cache_duration'] ) : 300; set_transient( $cache_key, $body, max( 60, $cache_duration ) ); $this->logger->add_log_entry( 'info', __( 'Feed content fetched and cached.', 'ainp-ai-native-publisher' ), 'URL: ' . esc_url( $feed_url ) . ' | Duration: ' . $cache_duration . 's' ); } else { $this->logger->add_log_entry( 'info', __( 'Feed content fetched (caching disabled).', 'ainp-ai-native-publisher' ), 'URL: ' . esc_url( $feed_url ) ); } break; } else { /* translators: 1: Feed URL, 2: Attempt number */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Empty response body received for feed: %1$s (Attempt %2$d)', 'ainp-ai-native-publisher' ), esc_url( $feed_url ), $attempt ) ); } } else { $error_message = is_wp_error( $response ) ? $response->get_error_message() : 'HTTP Status Code: ' . wp_remote_retrieve_response_code( $response ); /* translators: 1: Feed URL, 2: Attempt number */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Failed to fetch feed: %1$s (Attempt %2$d)', 'ainp-ai-native-publisher' ), esc_url( $feed_url ), $attempt ), $error_message ); } if ( $attempt < $max_retries ) { sleep( 2 * $attempt ); } } // End retry loop. } if ( empty( $body ) ) { /* translators: %s: Feed URL */ $this->logger->add_log_entry( 'error', sprintf( __( 'Failed to retrieve content for feed after all attempts: %s', 'ainp-ai-native-publisher' ), esc_url( $feed_url ) ) ); $summary['errors']++; return $summary; } // Security Fix: Removed LIBXML_NOENT to prevent XXE vulnerabilities. // Only LIBXML_NOCDATA is used to handle CDATA sections correctly. libxml_use_internal_errors( true ); $xml = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NOCDATA ); libxml_clear_errors(); libxml_use_internal_errors( false ); if ( false === $xml ) { /* translators: %s: Feed URL */ $this->logger->add_log_entry( 'error', sprintf( __( 'Failed to parse XML content from feed: %s.', 'ainp-ai-native-publisher' ), esc_url( $feed_url ) ), __( 'The feed content might be malformed.', 'ainp-ai-native-publisher' ) ); $summary['errors']++; return $summary; } $items_to_process = $this->extract_items_from_xml( $xml, $search_depth, $feed_url ); if ( empty( $items_to_process ) ) { /* translators: %s: Feed URL */ $this->logger->add_log_entry( 'info', sprintf( __( 'No new or processable items found in feed %s.', 'ainp-ai-native-publisher' ), esc_url( $feed_url ) ) ); $this->update_domain_timestamp( wp_parse_url( $feed_url, PHP_URL_HOST ) ); return $summary; } /* translators: 1: Number of items, 2: Feed URL */ $this->logger->add_log_entry( 'info', sprintf( __( 'Found %1$d items to process in feed %2$s.', 'ainp-ai-native-publisher' ), count( $items_to_process ), esc_url( $feed_url ) ) ); foreach ( $items_to_process as $item_data ) { if ( $this->logger->is_cancellation_requested() ) { $this->logger->add_log_entry( 'warning', __( 'Feed batch processing cancelled by user.', 'ainp-ai-native-publisher' ) ); break; } $delay = isset( $this->options['api_request_delay'] ) ? absint( $this->options['api_request_delay'] ) : 0; if ( $delay > 0 ) { sleep( $delay ); } $result = $this->process_single_item( $item_data, $feed_url, $is_manual_url ); if ( 'skipped' === $result ) { $summary['skipped']++; } elseif ( is_wp_error( $result ) ) { $summary['errors']++; } elseif ( is_numeric( $result ) && $result > 0 ) { $summary['created']++; } } return $summary; } /** * Processes a single extracted item into a WordPress post. * * @param array $item_data Associative array containing item data. * @param string $source_url The URL of the original source. * @param bool $is_manual_url True if this is part of a manual 'Fetch Specific URL' action. * @return int|string|WP_Error Post ID on success, 'skipped', or WP_Error. */ private function process_single_item( $item_data, $source_url, $is_manual_url ) { $item_processing_timeout = isset( $this->options['item_processing_timeout'] ) ? (int) $this->options['item_processing_timeout'] : 180; $is_cron_call = ( defined( 'DOING_CRON' ) && DOING_CRON && ! get_transient( 'ainp_manual_context' ) ); $current_item_timeout = $is_cron_call ? 0 : $item_processing_timeout; $item_start_time = time(); $title = $item_data['title'] ?? ''; $link = $item_data['link'] ?? ''; $description = $item_data['description'] ?? ''; $guid = $item_data['guid'] ?? $link; $feed_title = $item_data['feed_title'] ?? __( 'Unknown Source', 'ainp-ai-native-publisher' ); $item_xml_string = $item_data['item_xml_string'] ?? null; /* translators: %s: Item title */ $this->logger->add_log_entry( 'info', sprintf( __( 'Processing item: "%s"', 'ainp-ai-native-publisher' ), esc_html( $title ) ), 'GUID: ' . esc_html( $guid ) ); // --- Pre-processing Checks --- if ( ! $this->check_keyword_filters( $title, $description ) ) { return 'skipped'; } if ( $this->is_duplicate_post( $guid, $title ) ) { return 'skipped'; } // --- AI Processing --- $ai_results = array( 'rewritten_content' => wp_kses_post( $description ), // Default to cleaned original 'final_title' => $title, 'category_ids' => array( get_option( 'default_category' ) ), 'tags' => array(), ); // 1. Content Rewrite $rewrite_result = $this->perform_ai_content_rewrite( $ai_results['rewritten_content'], $title, $guid, $item_start_time, $current_item_timeout ); if ( is_wp_error( $rewrite_result ) ) { return $rewrite_result; } // Stop if rewrite is required but fails if ( false !== $rewrite_result ) { $ai_results['rewritten_content'] = $rewrite_result; } // Update content if rewrite succeeded or was skipped // Append source credits if enabled if ( ! empty( $this->options['module_source_credit'] ) ) { // FIX: Ensure wp_parse_url returns a string before passing to preg_replace (Fixes PHP Deprecated Warning) $host = wp_parse_url( $link, PHP_URL_HOST ); $host = $host ? (string) $host : ''; $source_name = preg_replace( '/^www\./i', '', $host ) ?: $feed_title; $credit_line_template = $this->options['source_credit_format'] ?? ''; $credit_line = str_replace( array( '{source_name}', '{original_link}' ), array( esc_html( $source_name ), esc_url( $link ) ), $credit_line_template ); $ai_results['rewritten_content'] = trim( $ai_results['rewritten_content'] ) . "\n\n" . trim( $credit_line ); } // 2. Title Generation $title_result = $this->perform_ai_title_generation( $title, $ai_results['rewritten_content'], $item_start_time, $current_item_timeout ); if ( is_wp_error( $title_result ) ) { return $title_result; } // Stop on timeout/cancel if ( false !== $title_result ) { $ai_results['final_title'] = $title_result; } // Update title if generated // 3. Category Assignment $category_result = $this->perform_ai_category_assignment( $ai_results['rewritten_content'], $ai_results['final_title'], $item_start_time, $current_item_timeout ); if ( is_wp_error( $category_result ) ) { return $category_result; } // Stop on timeout/cancel if ( false !== $category_result ) { $ai_results['category_ids'] = $category_result; } // Update categories // 4. Tag Generation $tags_result = $this->perform_ai_tag_generation( $ai_results['rewritten_content'], $ai_results['final_title'], $item_start_time, $current_item_timeout ); if ( is_wp_error( $tags_result ) ) { return $tags_result; } // Stop on timeout/cancel if ( false !== $tags_result ) { $ai_results['tags'] = $tags_result; } // Update tags // --- Post Creation --- $post_creation_result = $this->create_wordpress_post( $ai_results, $is_cron_call, $item_start_time, $current_item_timeout, $item_xml_string ); if ( is_wp_error( $post_creation_result ) ) { return $post_creation_result; } // Stop on error or timeout $post_id = $post_creation_result['post_id']; $initial_post_status = $post_creation_result['initial_status']; $final_post_status = $post_creation_result['final_status']; // --- Post-Creation Tasks --- $post_tasks_result = $this->handle_post_creation_tasks( $post_id, $ai_results['final_title'], $initial_post_status, $final_post_status, $item_xml_string, $ai_results['rewritten_content'] ); if ( 'skipped' === $post_tasks_result ) { return 'skipped'; } // If deleted due to post-creation duplicate check // --- Final Metadata & Timestamp --- update_post_meta( $post_id, '_ainp_guid', $guid ); update_post_meta( $post_id, '_ainp_original_link', esc_url_raw( $link ) ); update_post_meta( $post_id, '_ainp_source_url', esc_url_raw( $source_url ) ); if ( __( 'Unknown Source', 'ainp-ai-native-publisher' ) !== $feed_title ) { update_post_meta( $post_id, '_ainp_source_name', sanitize_text_field( $feed_title ) ); } $this->update_domain_timestamp( wp_parse_url( $source_url, PHP_URL_HOST ) ); return $post_id; // Return the new post ID on success. } /** * Helper function for AI content rewrite. * * @param string $content_to_rewrite Original cleaned content. * @param string $title Original item title (for logging). * @param string $guid Item GUID (for logging). * @param int $item_start_time Timestamp for timeout check. * @param int $item_processing_timeout Timeout duration (0 to disable). * @return string|false|WP_Error Rewritten content, false if module disabled, WP_Error on failure/timeout/cancel. */ private function perform_ai_content_rewrite( $content_to_rewrite, $title, $guid, $item_start_time, $item_processing_timeout ) { if ( empty( $this->options['module_rewrite_content'] ) ) { $this->logger->add_log_entry( 'info', __( 'AI content rewrite module disabled. Using original (cleaned) content.', 'ainp-ai-native-publisher' ), 'GUID: ' . $guid ); return false; // Module disabled } $provider_name = ucfirst( $this->options['main_ia_provider'] ?? 'gemini' ); $api_key_check_passed = ( 'gemini' === $this->options['main_ia_provider'] && ! empty( $this->options['gemini_api_key'] ) ) || ( 'groq' === $this->options['main_ia_provider'] && ! empty( $this->options['groq_api_key'] ) ); if ( ! $api_key_check_passed ) { /* translators: %s: AI provider name (e.g., Gemini, Groq) */ $error = new WP_Error( 'api_key_missing', sprintf( __( '%s API key is not configured. Cannot rewrite content.', 'ainp-ai-native-publisher' ), $provider_name ) ); $this->logger->add_log_entry( 'error', $error->get_error_message() ); return $error; } $timeout_check = $this->handle_timeout_or_cancel( __( 'content rewrite', 'ainp-ai-native-publisher' ), $title, $item_start_time, $item_processing_timeout ); if ( is_wp_error( $timeout_check ) ) { return $timeout_check; } $rewritten_content_result = $this->api_handler->rewrite_content( $content_to_rewrite ); if ( is_wp_error( $rewritten_content_result ) ) { /* translators: %s: Item title */ $this->logger->add_log_entry( 'error', sprintf( __( 'AI Error (content rewrite) for "%s".', 'ainp-ai-native-publisher' ), esc_html( $title ) ), $rewritten_content_result->get_error_message() ); $this->logger->add_log_entry( 'warning', __( 'Proceeding with original (cleaned) content due to rewrite error.', 'ainp-ai-native-publisher' ), 'GUID: ' . $guid ); return false; // Indicate failure but allow proceeding with original content } /* translators: 1: Item title, 2: AI provider name */ $this->logger->add_log_entry( 'info', sprintf( __( 'Content for "%1$s" successfully rewritten via %2$s.', 'ainp-ai-native-publisher' ), esc_html( $title ), $provider_name ) ); return $rewritten_content_result; } /** * Helper function for AI title generation. * * @param string $original_title Original item title. * @param string $rewritten_content Rewritten content for context. * @param int $item_start_time Timestamp for timeout check. * @param int $item_processing_timeout Timeout duration (0 to disable). * @return string|false|WP_Error Generated title, false if module disabled or failed non-critically, WP_Error on critical failure/timeout/cancel. */ private function perform_ai_title_generation( $original_title, $rewritten_content, $item_start_time, $item_processing_timeout ) { if ( empty( $this->options['module_generate_title'] ) ) { $this->logger->add_log_entry( 'info', __( 'AI title generation module disabled. Using original title.', 'ainp-ai-native-publisher' ) ); return false; // Module disabled } $timeout_check = $this->handle_timeout_or_cancel( __( 'title generation', 'ainp-ai-native-publisher' ), $original_title, $item_start_time, $item_processing_timeout ); if ( is_wp_error( $timeout_check ) ) { return $timeout_check; } $new_title_result = $this->api_handler->rewrite_title( $original_title, $rewritten_content ); if ( is_wp_error( $new_title_result ) ) { /* translators: %s: Original item title */ $this->logger->add_log_entry( 'error', sprintf( __( 'AI Error (title generation) for "%s". Using original title.', 'ainp-ai-native-publisher' ), esc_html( $original_title ) ), $new_title_result->get_error_message() ); return false; // Indicate failure but allow proceeding with original title } /* translators: 1: Original title, 2: New title */ $this->logger->add_log_entry( 'info', sprintf( __( 'AI generated new title for "%1$s": "%2$s".', 'ainp-ai-native-publisher' ), esc_html( $original_title ), esc_html( $new_title_result ) ) ); return $new_title_result; } /** * Helper function for AI category assignment. * * @param string $rewritten_content Rewritten content for context. * @param string $final_title Final post title (for logging). * @param int $item_start_time Timestamp for timeout check. * @param int $item_processing_timeout Timeout duration (0 to disable). * @return array|false|WP_Error Array of category IDs, false if module disabled or failed non-critically, WP_Error on critical failure/timeout/cancel. */ private function perform_ai_category_assignment( $rewritten_content, $final_title, $item_start_time, $item_processing_timeout ) { if ( empty( $this->options['module_generate_category'] ) ) { $this->logger->add_log_entry( 'info', __( 'AI categorization module disabled. Using default WordPress category.', 'ainp-ai-native-publisher' ) ); return false; // Module disabled } $timeout_check = $this->handle_timeout_or_cancel( __( 'categorization', 'ainp-ai-native-publisher' ), $final_title, $item_start_time, $item_processing_timeout ); if ( is_wp_error( $timeout_check ) ) { return $timeout_check; } $category_name_result = $this->api_handler->categorize_content( $rewritten_content ); // get_or_create_category_id handles WP_Error internally and logs, returns default ID array on error. return $this->get_or_create_category_id( $category_name_result, $final_title ); } /** * Helper function for AI tag generation. * * @param string $rewritten_content Rewritten content for context. * @param string $final_title Final post title (for logging). * @param int $item_start_time Timestamp for timeout check. * @param int $item_processing_timeout Timeout duration (0 to disable). * @return array|false|WP_Error Array of tag names, false if module disabled or failed non-critically, WP_Error on critical failure/timeout/cancel. */ private function perform_ai_tag_generation( $rewritten_content, $final_title, $item_start_time, $item_processing_timeout ) { if ( empty( $this->options['module_generate_tags'] ) ) { $this->logger->add_log_entry( 'info', __( 'AI tag generation module disabled.', 'ainp-ai-native-publisher' ) ); return false; // Module disabled } $timeout_check = $this->handle_timeout_or_cancel( __( 'tag generation', 'ainp-ai-native-publisher' ), $final_title, $item_start_time, $item_processing_timeout ); if ( is_wp_error( $timeout_check ) ) { return $timeout_check; } $tags_result = $this->api_handler->generate_tags( $rewritten_content ); if ( is_wp_error( $tags_result ) ) { /* translators: %s: Post title */ $this->logger->add_log_entry( 'error', sprintf( __( 'AI Error (tag generation) for "%s". No tags will be added.', 'ainp-ai-native-publisher' ), esc_html( $final_title ) ), $tags_result->get_error_message() ); return false; // Indicate failure but allow proceeding without tags } if ( is_array( $tags_result ) ) { $post_tags = array_map( 'sanitize_text_field', $tags_result ); if ( ! empty( $post_tags ) ) { /* translators: 1: Post title, 2: Comma-separated list of tags */ $this->logger->add_log_entry( 'info', sprintf( __( 'AI generated tags for "%1$s": %2$s', 'ainp-ai-native-publisher' ), esc_html( $final_title ), implode( ', ', $post_tags ) ) ); return $post_tags; } else { /* translators: %s: Post title */ $this->logger->add_log_entry( 'info', sprintf( __( 'AI did not return any valid tags for "%s".', 'ainp-ai-native-publisher' ), esc_html( $final_title ) ) ); return array(); // Return empty array if AI returned none } } return false; } /** * Helper function to create the WordPress post. * * @param array $ai_results Contains final_title, rewritten_content, category_ids, tags. * @param bool $is_cron_call Indicates if running via cron. * @param int $item_start_time Timestamp for timeout check. * @param int $item_processing_timeout Timeout duration (0 to disable). * @param string|null $item_xml_string Original XML string for image handler. * @return array|WP_Error Array ['post_id', 'initial_status', 'final_status'] on success, WP_Error on failure/timeout/cancel. */ private function create_wordpress_post( $ai_results, $is_cron_call, $item_start_time, $item_processing_timeout, $item_xml_string = null ) { $timeout_check = $this->handle_timeout_or_cancel( __( 'WordPress post creation', 'ainp-ai-native-publisher' ), $ai_results['final_title'], $item_start_time, $item_processing_timeout ); if ( is_wp_error( $timeout_check ) ) { return $timeout_check; } $post_author_id = ( ! $is_cron_call && function_exists( 'get_current_user_id' ) && 0 !== get_current_user_id() ) ? get_current_user_id() : ( ! empty( $this->options['default_author_id'] ) ? absint( $this->options['default_author_id'] ) : 1 ); $initial_post_status = $this->options['default_post_status'] ?? 'draft'; $final_post_status = $initial_post_status; // Parse the temporary XML string here once before image checks $item_obj_for_image_check = null; if ( $item_xml_string ) { // Security Fix: Removed LIBXML_NOENT $libxml_previous = libxml_use_internal_errors( true ); $item_obj_for_image_check = simplexml_load_string( wp_unslash( $item_xml_string ), 'SimpleXMLElement', LIBXML_NOCDATA ); libxml_use_internal_errors( $libxml_previous ); } $needs_ia_image_due_to_settings = ( ! empty( $this->options['module_force_ia_image'] ) || ( ! empty( $this->options['module_generate_images'] ) && empty( $this->image_handler->extract_image_url_from_feed_item( $item_obj_for_image_check ) ) ) ); if ( $needs_ia_image_due_to_settings && 'async' === ( $this->options['image_processing_mode'] ?? 'async' ) ) { $initial_post_status = AINP_AWAITING_IMAGE_STATUS; /* translators: 1: Post title, 2: Final intended status */ $this->logger->add_log_entry( 'info', sprintf( __( 'Post "%1$s" will be initially created with status "Awaiting Image" before setting to "%2$s".', 'ainp-ai-native-publisher' ), esc_html( $ai_results['final_title'] ), $final_post_status ) ); } $post_data = array( 'post_title' => wp_strip_all_tags( $ai_results['final_title'] ), 'post_content' => wp_kses_post( $ai_results['rewritten_content'] ), 'post_status' => $initial_post_status, 'post_author' => $post_author_id, 'post_category' => $ai_results['category_ids'], 'tags_input' => $ai_results['tags'], ); $post_id = wp_insert_post( $post_data, true ); if ( is_wp_error( $post_id ) ) { /* translators: %s: Post title */ $this->logger->add_log_entry( 'error', sprintf( __( 'Error creating WordPress post for "%s".', 'ainp-ai-native-publisher' ), esc_html( $ai_results['final_title'] ) ), $post_id->get_error_message() ); return $post_id; } /* translators: 1: Post title, 2: New Post ID, 3: Initial post status */ $this->logger->add_log_entry( 'success', sprintf( __( 'Post "%1$s" created successfully (ID: %2$d) with initial status "%3$s".', 'ainp-ai-native-publisher' ), esc_html( $ai_results['final_title'] ), $post_id, $initial_post_status ), 'Post ID: ' . $post_id ); return array( 'post_id' => $post_id, 'initial_status' => $initial_post_status, 'final_status' => $final_post_status, ); } /** * Handles tasks after post creation: secondary duplicate check, image processing. * * @param int $post_id ID of the newly created post. * @param string $final_title Final title of the post. * @param string $initial_post_status Initial status post was created with. * @param string $final_post_status Intended final status after image processing. * @param string|null $item_xml_string Original XML string for image handler. * @param string $rewritten_content Rewritten content for image handler context. * @return string|true Returns 'skipped' if post deleted due to duplicate, true otherwise. */ private function handle_post_creation_tasks( $post_id, $final_title, $initial_post_status, $final_post_status, $item_xml_string, $rewritten_content ) { // Post-creation duplicate check if ( $this->check_for_live_duplicate( $post_id, $final_title ) ) { wp_delete_post( $post_id, true ); // Force delete /* translators: 1: Post title, 2: Post ID */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Post "%1$s" (ID: %2$d) was deleted after creation due to similarity with an existing published post.', 'ainp-ai-native-publisher' ), esc_html( $final_title ), $post_id ) ); return 'skipped'; } // Add to awaiting list if needed if ( AINP_AWAITING_IMAGE_STATUS === $initial_post_status ) { $this->posts_awaiting_image_current_run[] = $post_id; if ( $initial_post_status !== $final_post_status ) { update_post_meta( $post_id, AINP_FINAL_STATUS_META_KEY, $final_post_status ); } } // Store XML for async image processing if needed if ( ! empty( $item_xml_string ) && 'async' === ( $this->options['image_processing_mode'] ?? 'async' ) ) { update_post_meta( $post_id, AINP_FEED_ITEM_META_KEY, wp_slash( $item_xml_string ) ); } // --- Image Processing --- $item_obj_for_image_check = null; if ( $item_xml_string ) { // Security Fix: Removed LIBXML_NOENT $libxml_previous = libxml_use_internal_errors( true ); $item_obj_for_image_check = simplexml_load_string( wp_unslash( $item_xml_string ), 'SimpleXMLElement', LIBXML_NOCDATA ); libxml_use_internal_errors( $libxml_previous ); } if ( 'sync' === ( $this->options['image_processing_mode'] ?? 'async' ) ) { // Synchronous Image Processing /* translators: %d: Post ID */ $this->logger->add_log_entry( 'info', sprintf( __( 'Processing image for post ID %d (Synchronous Mode).', 'ainp-ai-native-publisher' ), $post_id ) ); $image_set = $this->image_handler->set_featured_image_from_feed_or_ia( $post_id, $item_obj_for_image_check, $final_title, $rewritten_content ); if ( $image_set && AINP_AWAITING_IMAGE_STATUS === get_post_status( $post_id ) ) { wp_update_post( array( 'ID' => $post_id, 'post_status' => $final_post_status ) ); /* translators: 1: Post ID, 2: Final post status */ $this->logger->add_log_entry( 'success', sprintf( __( 'Post status for ID %1$d updated to "%2$s" after synchronous image processing.', 'ainp-ai-native-publisher' ), $post_id, $final_post_status ) ); delete_post_meta( $post_id, AINP_FINAL_STATUS_META_KEY ); } elseif ( ! $image_set && 'publish' === $initial_post_status ) { wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) ); /* translators: %d: Post ID */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Post ID %d was set to "Draft" instead of "Publish" because no image could be set in synchronous mode.', 'ainp-ai-native-publisher' ), $post_id ) ); } delete_post_meta( $post_id, AINP_FEED_ITEM_META_KEY ); } else { // Asynchronous Image Processing (Default) $needs_ia_image_due_to_settings = ( ! empty( $this->options['module_force_ia_image'] ) || ( ! empty( $this->options['module_generate_images'] ) && empty( $this->image_handler->extract_image_url_from_feed_item( $item_obj_for_image_check ) ) ) ); if ( $needs_ia_image_due_to_settings ) { $this->schedule_image_processing_task( $post_id ); } else { $image_url_from_feed = $this->image_handler->extract_image_url_from_feed_item( $item_obj_for_image_check ); if ( ! empty( $image_url_from_feed ) ) { $attach_id = $this->image_handler->set_featured_image_from_url( $post_id, $image_url_from_feed, $final_title ); if ( is_wp_error( $attach_id ) ) { /* translators: 1: Post ID, 2: Error message */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Failed to set image from feed URL for post ID %d in async mode.', 'ainp-ai-native-publisher' ), $post_id ), $attach_id->get_error_message() ); if ( 'publish' === $initial_post_status ) { wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) ); /* translators: %d: Post ID */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Post ID %d was set to "Draft" because feed image download failed.', 'ainp-ai-native-publisher' ), $post_id ) ); } } } elseif ( 'publish' === $initial_post_status ) { wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) ); /* translators: %d: Post ID */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Post ID %d was set to "Draft" because no feed image was found and AI fallback was not triggered.', 'ainp-ai-native-publisher' ), $post_id ) ); } delete_post_meta( $post_id, AINP_FEED_ITEM_META_KEY ); delete_post_meta( $post_id, AINP_FINAL_STATUS_META_KEY ); } } return true; } /** * Checks for timeouts or user cancellation requests. * * @param string $stage Description of the current processing stage. * @param string $title Item title for logging. * @param int $item_start_time Timestamp processing began. * @param int $item_processing_timeout Max allowed seconds (0 for no check). * @return WP_Error|false WP_Error if cancelled/timed out, false otherwise. */ private function handle_timeout_or_cancel( $stage, $title, $item_start_time, $item_processing_timeout ) { if ( $this->logger->is_cancellation_requested() ) { /* translators: 1: Processing stage, 2: Item title */ $this->logger->add_log_entry( 'warning', sprintf( __( 'Processing cancelled by user request during "%1$s" stage for item "%2$s".', 'ainp-ai-native-publisher' ), esc_html( $stage ), esc_html( $title ) ) ); return new WP_Error( 'cancelled', __( 'Processing was cancelled by the user.', 'ainp-ai-native-publisher' ) ); } if ( $item_processing_timeout > 0 && ( time() - $item_start_time ) > $item_processing_timeout ) { /* translators: 1: Timeout duration, 2: Processing stage, 3: Item title */ $this->logger->add_log_entry( 'error', sprintf( __( 'TIMEOUT (%1$d sec) reached during "%2$s" stage for item "%3$s". Skipping item.', 'ainp-ai-native-publisher' ), $item_processing_timeout, esc_html( $stage ), esc_html( $title ) ) ); /* translators: 1: Item title, 2: Timeout duration, 3: Processing stage */ return new WP_Error( 'item_timeout', sprintf( __( 'Processing of item "%1$s" exceeded the configured time limit (%2$d sec) during the %3$s stage.', 'ainp-ai-native-publisher' ), esc_html( $title ), $item_processing_timeout, esc_html( $stage ) ) ); } return false; } /** * Extracts and prepares item data from a SimpleXMLElement feed object. * * @param SimpleXMLElement $xml The parsed XML object. * @param string|int $search_depth How many items to extract. * @param string $feed_url Feed URL for logging. * @return array Array of item data arrays. */ private function extract_items_from_xml( $xml, $search_depth, $feed_url = '' ) { $feed_title = __( 'Unknown Source', 'ainp-ai-native-publisher' ); if ( isset( $xml->channel->title ) ) { $feed_title = (string) $xml->channel->title; } elseif ( isset( $xml->title ) ) { $feed_title = (string) $xml->title; } $all_items_in_feed = array(); if ( isset( $xml->channel->item ) ) { $all_items_in_feed = $xml->channel->item; } elseif ( isset( $xml->entry ) ) { $all_items_in_feed = $xml->entry; } if ( empty( $all_items_in_feed ) ) { $this->logger->add_log_entry( 'info', __( 'No or elements found in the feed.', 'ainp-ai-native-publisher' ), 'Feed URL: ' . esc_url( $feed_url ) ); return array(); } $extracted_items = array(); $limit = ( 'all' === $search_depth || ! is_numeric( $search_depth ) ) ? count( $all_items_in_feed ) : max( 1, (int) $search_depth ); foreach ( $all_items_in_feed as $item_candidate ) { if ( count( $extracted_items ) >= $limit ) { break; } $data = array( 'item_xml_string' => $item_candidate->asXML(), 'feed_title' => $feed_title, ); if ( isset( $xml->channel->item ) ) { // RSS $raw_title = (string) ( $item_candidate->title ?? '' ); $data['title'] = html_entity_decode( $raw_title, ENT_QUOTES | ENT_XML1, 'UTF-8' ); $data['link'] = (string) ( $item_candidate->link ?? '' ); $data['description'] = (string) ( $item_candidate->description ?? ( $item_candidate->children( 'content', true )->encoded ?? '' ) ); $data['guid'] = (string) ( $item_candidate->guid ?? $data['link'] ); } elseif ( isset( $xml->entry ) ) { // Atom $raw_title = (string) ( $item_candidate->title ?? '' ); $data['title'] = html_entity_decode( $raw_title, ENT_QUOTES | ENT_XML1, 'UTF-8' ); $data['link'] = (string) ( $item_candidate->link[0]['href'] ?? ( $item_candidate->link['href'] ?? '' ) ); $data['description'] = (string) ( $item_candidate->summary ?? ( $item_candidate->content ?? '' ) ); $data['guid'] = (string) ( $item_candidate->id ?? $data['link'] ); } else { continue; } if ( empty( $data['title'] ) || empty( $data['link'] ) || empty( $data['guid'] ) ) { $this->logger->add_log_entry( 'warning', __( 'SKIPPED ITEM: Feed item missing essential data (title, link, or guid).', 'ainp-ai-native-publisher' ), 'Feed: ' . esc_url( $feed_url ) . ' | Title: ' . esc_html( $data['title'] ) ); continue; } if ( $this->is_duplicate_post( $data['guid'], $data['title'] ) ) { continue; } $extracted_items[] = $data; } return $extracted_items; } /** * Gets/creates category ID. Handles errors. */ private function get_or_create_category_id( $category_name_result, $post_title_for_log ) { $default_category_id = get_option( 'default_category' ); $category_name = __( 'General', 'ainp-ai-native-publisher' ); if ( ! is_wp_error( $category_name_result ) && ! empty( trim( $category_name_result ) ) ) { $potential_name = sanitize_text_field( trim( $category_name_result ) ); if ( strlen( $potential_name ) < 100 ) { $category_name = $potential_name; } else { $this->logger->add_log_entry( 'warning', __( 'AI suggested category name is too long, using default.', 'ainp-ai-native-publisher' ), 'Suggested: ' . esc_html( $potential_name ) ); } } elseif ( is_wp_error( $category_name_result ) ) { $this->logger->add_log_entry( 'warning', __( 'AI failed to suggest a category, using default.', 'ainp-ai-native-publisher' ), $category_name_result->get_error_message() ); } /* translators: 1: Post title, 2: Category name */ $this->logger->add_log_entry( 'info', __( 'Attempting to set category for post.', 'ainp-ai-native-publisher' ), sprintf( 'Post: "%1$s" | Category: "%2$s"', esc_html( $post_title_for_log ), esc_html( $category_name ) ) ); $term = term_exists( $category_name, 'category' ); if ( $term && is_array( $term ) && isset( $term['term_id'] ) ) { return array( (int) $term['term_id'] ); } $new_term = wp_insert_term( $category_name, 'category' ); if ( is_wp_error( $new_term ) ) { /* translators: 1: Category name, 2: Error message */ $this->logger->add_log_entry( 'error', __( 'Error creating new category.', 'ainp-ai-native-publisher' ), sprintf( 'Name: %1$s | Error: %2$s', esc_html( $category_name ), $new_term->get_error_message() ) ); return array( (int) $default_category_id ); } return array( (int) $new_term['term_id'] ); } /** * Checks keyword filters. */ private function check_keyword_filters( $title, $description ) { if ( empty( $this->options['module_keyword_filters'] ) ) { return true; } $content_to_check = $title . ' ' . wp_strip_all_tags( $description ); $content_to_check_lower = mb_strtolower( $content_to_check, 'UTF-8' ); // Inclusion Filter $include_keywords_raw = $this->options['include_keywords'] ?? ''; if ( ! empty( trim( $include_keywords_raw ) ) ) { $include_keywords = array_filter( array_map( 'trim', explode( "\n", $include_keywords_raw ) ) ); $found_include = false; if ( ! empty( $include_keywords ) ) { foreach ( $include_keywords as $keyword ) { if ( false !== mb_strpos( $content_to_check_lower, mb_strtolower( trim( $keyword ), 'UTF-8' ) ) ) { $found_include = true; break; } } if ( ! $found_include ) { /* translators: %s: Item title */ $this->logger->add_log_entry( 'warning', sprintf( __( 'ITEM SKIPPED (Inclusion Filter): Item "%s" does not contain any of the required inclusion keywords.', 'ainp-ai-native-publisher' ), esc_html( $title ) ), 'Keywords: ' . esc_html( implode( ', ', $include_keywords ) ) ); return false; } } } // Exclusion Filter $exclude_keywords_raw = $this->options['exclude_keywords'] ?? ''; if ( ! empty( trim( $exclude_keywords_raw ) ) ) { $exclude_keywords = array_filter( array_map( 'trim', explode( "\n", $exclude_keywords_raw ) ) ); if ( ! empty( $exclude_keywords ) ) { foreach ( $exclude_keywords as $keyword ) { if ( false !== mb_strpos( $content_to_check_lower, mb_strtolower( trim( $keyword ), 'UTF-8' ) ) ) { /* translators: 1: Item title, 2: Forbidden keyword */ $this->logger->add_log_entry( 'warning', sprintf( __( 'ITEM SKIPPED (Exclusion Filter): Item "%1$s" contains the forbidden keyword "%2$s".', 'ainp-ai-native-publisher' ), esc_html( $title ), esc_html( $keyword ) ) ); return false; } } } } return true; } /** * Checks for duplicate posts (GUID and title similarity). */ private function is_duplicate_post( $guid, $new_title ) { $query_guid = new WP_Query( array( 'post_type' => 'post', 'post_status' => 'any', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_key' => '_ainp_guid', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'meta_value' => $guid, 'fields' => 'ids', 'posts_per_page' => 1, 'no_found_rows' => true, ) ); if ( $query_guid->have_posts() ) { $this->logger->add_log_entry( 'info', __( 'ITEM SKIPPED (Duplicate GUID): A post with the same GUID already exists.', 'ainp-ai-native-publisher' ), 'GUID: ' . esc_html( $guid ) ); return true; } if ( ! empty( $new_title ) ) { $normalized_new_title = trim( mb_strtolower( $new_title, 'UTF-8' ) ); $days_limit = max( 1, (int) AINP_DUPLICATE_CHECK_DAYS_LIMIT ); $live_posts_query = new WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 50, 'orderby' => 'date', 'order' => 'DESC', 'date_query' => array( array( 'after' => "$days_limit days ago", 'inclusive' => true ) ), 'fields' => 'ids', 'no_found_rows' => true, ) ); if ( $live_posts_query->have_posts() ) { foreach ( $live_posts_query->posts as $post_id ) { $existing_title = get_the_title( $post_id ); $normalized_existing_title = trim( mb_strtolower( $existing_title, 'UTF-8' ) ); similar_text( $normalized_new_title, $normalized_existing_title, $similarity_percent ); if ( $similarity_percent >= AINP_DUPLICATE_TITLE_SIMILARITY ) { /* translators: 1: New title, 2: Existing ID, 3: Existing title, 4: Similarity % */ $this->logger->add_log_entry( 'warning', __( 'ITEM SKIPPED (Similar Title): Found an existing published post with a similar title.', 'ainp-ai-native-publisher' ), sprintf( 'New: "%1$s" | Existing (ID %2$d): "%3$s" (Similarity %.2f%%)', esc_html( $new_title ), $post_id, esc_html( $existing_title ), $similarity_percent ) ); return true; } } } } return false; } /** * Performs post-creation duplicate check against live posts. */ private function check_for_live_duplicate( $post_id_to_check, $title_to_check ) { $normalized_new_title = trim( mb_strtolower( $title_to_check, 'UTF-8' ) ); // Optimization: Removed post__not_in to avoid performance warning. // Filtering is done in the loop below. $live_posts_query = new WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => 10, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ) ); if ( $live_posts_query->have_posts() ) { foreach ( $live_posts_query->posts as $post_id ) { if ( (int) $post_id === (int) $post_id_to_check ) { continue; } $existing_title = get_the_title( $post_id ); $normalized_existing_title = trim( mb_strtolower( $existing_title, 'UTF-8' ) ); similar_text( $normalized_new_title, $normalized_existing_title, $similarity_percent ); if ( $similarity_percent >= AINP_DUPLICATE_TITLE_SIMILARITY ) { /* translators: 1: New ID, 2: New title, 3: Existing ID, 4: Existing title, 5: Similarity % */ $this->logger->add_log_entry( 'error', __( 'DUPLICATE DETECTED POST-CREATION: Similar title found in a published post. Deleting the newly created post.', 'ainp-ai-native-publisher' ), sprintf( 'New (ID %1$d): "%2$s" is too similar to Published (ID %3$d): "%4$s" (Similarity %.2f%%)', $post_id_to_check, esc_html( $title_to_check ), $post_id, esc_html( $existing_title ), $similarity_percent ) ); return true; } } } return false; } /** * Schedules a single WP Cron event for image processing. */ public function schedule_image_processing_task( $post_id ) { if ( wp_next_scheduled( AINP_GENERATE_IMAGE_HOOK, array( 'post_id' => $post_id ) ) ) { /* translators: %d: Post ID */ $this->logger->add_log_entry( 'info', sprintf( __( 'Image processing task for post ID %d already scheduled.', 'ainp-ai-native-publisher' ), $post_id ) ); return; } $pending_count = (int) get_option( AINP_PENDING_IMAGE_TASKS_OPTION, 0 ); update_option( AINP_PENDING_IMAGE_TASKS_OPTION, $pending_count + 1 ); wp_schedule_single_event( time() + self::ASYNC_IMAGE_TASK_DELAY_SECONDS, AINP_GENERATE_IMAGE_HOOK, array( 'post_id' => $post_id ) ); /* translators: 1: Post ID, 2: Total pending tasks */ $this->logger->add_log_entry( 'info', sprintf( __( 'Image processing task for post ID %1$d has been SCHEDULED.', 'ainp-ai-native-publisher' ), $post_id ), 'Total tasks now in queue: ' . ( $pending_count + 1 ) ); } /** * Gets the list of post IDs awaiting image processing in the current run. */ public function get_posts_awaiting_image_current_run() { return $this->posts_awaiting_image_current_run; } /** * Sets/resets the list of post IDs awaiting image processing for the current run. */ public function set_posts_awaiting_image_current_run( array $posts ) { $this->posts_awaiting_image_current_run = $posts; } /** * Deletes posts still in 'awaiting_image' status at the end of a run. */ public function delete_awaiting_image_posts_from_current_run() { $deleted_count = 0; if ( empty( $this->posts_awaiting_image_current_run ) ) { return 0; } /* translators: %d: Number of posts */ $this->logger->add_log_entry( 'info', sprintf( __( 'Checking %d posts from the current run for deletion (status "Awaiting Image").', 'ainp-ai-native-publisher' ), count( $this->posts_awaiting_image_current_run ) ) ); foreach ( $this->posts_awaiting_image_current_run as $post_id ) { $post = get_post( $post_id ); if ( $post && AINP_AWAITING_IMAGE_STATUS === $post->post_status ) { if ( wp_delete_post( $post_id, false ) ) { // Move to trash $deleted_count++; /* translators: 1: Post ID, 2: Post title */ $this->logger->add_log_entry( 'success', sprintf( __( 'Post ID %1$d ("%2$s") moved to trash (remained "Awaiting Image" after fetch completion).', 'ainp-ai-native-publisher' ), $post_id, esc_html( $post->post_title ) ) ); } else { /* translators: %d: Post ID */ $this->logger->add_log_entry( 'error', sprintf( __( 'Failed to move post ID %d to trash.', 'ainp-ai-native-publisher' ), $post_id ) ); } } elseif ( $post && AINP_AWAITING_IMAGE_STATUS !== $post->post_status ) { /* translators: 1: Post ID, 2: Post Status */ $this->logger->add_log_entry( 'info', sprintf( __( 'Post ID %1$d status was updated to "%2$s", deletion skipped.', 'ainp-ai-native-publisher' ), $post_id, esc_html( $post->post_status ) ) ); } } $this->posts_awaiting_image_current_run = array(); // Clear list for current run return $deleted_count; } /** * Checks if a domain is on cooldown. */ private function is_domain_on_cooldown( $domain ) { if ( empty( $domain ) ) { return false; } $respect_interval_hours = isset( $this->options['respect_interval'] ) ? absint( $this->options['respect_interval'] ) : 0; if ( $respect_interval_hours <= 0 ) { return false; } if ( isset( $this->domain_timestamps[ $domain ] ) ) { $last_processed_time = (int) $this->domain_timestamps[ $domain ]; $cooldown_seconds = $respect_interval_hours * HOUR_IN_SECONDS; if ( time() < ( $last_processed_time + $cooldown_seconds ) ) { return true; } } return false; } /** * Updates the last processed timestamp for a domain. */ private function update_domain_timestamp( $domain ) { if ( empty( $domain ) ) { return; } $this->domain_timestamps[ $domain ] = time(); update_option( AINP_DOMAIN_TIMESTAMPS_OPTION, $this->domain_timestamps ); } } // End Class AINP_Post_Processor