* @license GPL-2.0+ https://www.gnu.org/licenses/gpl-2.0.html * @link https://aisk.chat */ use Opis\JsonSchema\Keywords\ConstKeyword; use Smalot\PdfParser\Parser; use Smalot\PdfParser\Exception\PdfParserException; // Include required classes require_once plugin_dir_path(__FILE__) . 'class-url-content-fetcher.php'; require_once plugin_dir_path(__FILE__) . 'class-url-discoverer.php'; require_once plugin_dir_path(__FILE__) . 'class-content-processor.php'; require_once plugin_dir_path(__FILE__) . 'class-crawler.php'; /** * AISK_External_Embeddings_Handler Class * * Manages the generation, storage, and retrieval of embeddings for various content types * * @category Class * @package AISK * @author Aisk Team * @license GPL-2.0+ https://www.gnu.org/licenses/gpl-2.0.html * @link https://aisk.chat */ class AISK_External_Embeddings_Handler { private $db; private $api_key; private $auth_key; private $model = 'text-embedding-3-small'; private $batch_size = 10; private $settings; private $max_upload_size; private $optimum_upload_size = 10485760; // 10MB default optimum size private $crawl_max_depth; private $crawl_max_pages; private $crawl_request_timeout; private $crawl_max_job_time; const MAX_PROCESSING_TIME = 300; // 5 minutes in seconds /** * PDFs with less extracted text than this are treated as image-only and sent to OCR */ const MIN_PDF_TEXT_LENGTH = 20; /** * Vision model used to read text from image-only PDFs. * gpt-4o reads small print on rendered page tiles far more accurately than gpt-4o-mini. */ const PDF_OCR_MODEL = 'gpt-4o'; /** * Limits for the page images the admin app renders for OCR */ const PDF_OCR_MAX_BUNDLE_SIZE = 64 * 1024 * 1024; const PDF_OCR_MAX_TILES = 300; const PDF_OCR_MAX_TILE_SIZE = 5 * 1024 * 1024; /** * Class constructor * * @param void * * @since 1.0.0 * * @return void */ public function __construct() { $this->settings = get_option('aisk_settings'); $this->api_key = isset($this->settings['general']['openai_key']) ? $this->settings['general']['openai_key'] : ''; $this->auth_key = isset($this->settings['general']['auth_key']) ? $this->settings['general']['auth_key'] : ''; // Configurable crawl parameters with defaults $this->crawl_max_depth = isset($this->settings['general']['crawl_max_depth']) ? intval($this->settings['general']['crawl_max_depth']) : 2; $this->crawl_max_pages = isset($this->settings['general']['crawl_max_pages']) ? intval($this->settings['general']['crawl_max_pages']) : 15; $this->crawl_request_timeout = isset($this->settings['general']['crawl_request_timeout']) ? intval($this->settings['general']['crawl_request_timeout']) : 15; $this->crawl_max_job_time = isset($this->settings['general']['crawl_max_job_time']) ? intval($this->settings['general']['crawl_max_job_time']) : 180; // Initialize max upload size $this->max_upload_size = $this->get_max_upload_size(); // Set optimum upload size from settings if available if (isset($this->settings['general']['optimum_upload_size'])) { $this->optimum_upload_size = intval($this->settings['general']['optimum_upload_size']) * 1024 * 1024; } // Create PDF queue table add_action('init', array($this, 'create_pdf_queue_table')); // Register PDF background processing action add_action('aisk_process_pdf_background', array($this, 'process_pdf_background'), 10, 3); // Register REST API endpoint for processing add_action('rest_api_init', [ $this, 'register_rest_routes' ]); add_action('delete_attachment', [ $this, 'delete_pdf_ocr_pages' ]); // Register background processing action add_action('aisk_process_url_background', [ $this, 'process_url_background' ]); // clean up embedding for deleted content add_action('before_delete_post', [ $this, 'delete_content_embeddings' ]); add_action('woocommerce_before_delete_product', [ $this, 'delete_content_embeddings' ]); add_action('woocommerce_before_delete_product_variation', [ $this, 'delete_content_embeddings' ]); add_filter('wp_handle_upload_prefilter', array($this, 'increase_upload_limit_for_pdf')); // Add admin notice for upload size limits add_action('admin_notices', array($this, 'display_upload_size_notice')); // Add script for upload instructions toggle add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts')); } public function enqueue_admin_scripts() { // Only enqueue on admin pages where we show the notice if (!current_user_can('manage_options')) { return; } // Check if we need to show the notice if ($this->max_upload_size < $this->optimum_upload_size) { wp_register_script( 'aisk-upload-instructions', false, ['jquery'], AISK_VERSION, true ); $script = " jQuery(document).ready(function($) { $('.show-upload-instructions').on('click', function(e) { e.preventDefault(); $('#upload-instructions').slideToggle(); }); }); "; wp_add_inline_script('aisk-upload-instructions', $script); wp_enqueue_script('aisk-upload-instructions'); } } /** * Register REST API routes * * @param void * * @since 1.0.0 * * @return void */ public function register_rest_routes() { register_rest_route('aisk/v1', '/process-urls', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_url_processing' ], 'permission_callback' => function () { return current_user_can('manage_options'); }, 'args' => [ 'website_url' => [ 'required' => true, 'type' => 'string', 'validate_callback' => function($param) { return !empty($param) && filter_var($param, FILTER_VALIDATE_URL); } ], 'follow_links' => [ 'required' => false, 'type' => 'boolean', 'default' => true ], 'include_paths' => [ 'required' => false, 'type' => 'array', 'default' => [] ], 'exclude_paths' => [ 'required' => false, 'type' => 'array', 'default' => [] ], 'include_selectors' => [ 'required' => false, 'type' => 'array', 'default' => [] ], 'exclude_selectors' => [ 'required' => false, 'type' => 'array', 'default' => [] ] ] ]); register_rest_route( 'aisk/v1', '/check-url-status', [ 'methods' => 'POST', 'callback' => [ $this, 'check_urls_status' ], 'permission_callback' => function () { return current_user_can('manage_options'); }, 'args' => [ 'urls' => [ 'required' => true, 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ] ] ); register_rest_route( 'aisk/v1', '/get-crawled-urls', [ 'methods' => 'POST', 'callback' => [ $this, 'get_crawled_urls' ], 'permission_callback' => function () { return current_user_can('manage_options'); }, ] ); register_rest_route( 'aisk/v1', '/delete-url', [ 'methods' => 'POST', 'callback' => [ $this, 'delete_crawled_url' ], 'permission_callback' => function () { return current_user_can('manage_options'); }, ] ); register_rest_route( 'aisk/v1', '/process-pdf', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_pdf_processing' ], 'permission_callback' => function ( $request ) { return current_user_can( 'upload_files' ) && wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ); }, ] ); register_rest_route( 'aisk/v1', '/get-pdf-status', [ 'methods' => 'GET', 'callback' => [ $this, 'get_pdf_status' ], 'permission_callback' => function ( $request ) { return current_user_can( 'upload_files' ) && wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ); }, 'args' => [ 'attachment_id' => [ 'required' => true, 'type' => 'integer', 'validate_callback' => function($param) { return is_numeric($param) && $param > 0; } ] ] ] ); register_rest_route( 'aisk/v1', '/pdf-job-status', [ 'methods' => 'GET', 'callback' => [ $this, 'get_pdf_job_status' ], 'permission_callback' => function ( $request ) { return current_user_can( 'upload_files' ) && wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ); }, 'args' => [ 'job_id' => [ 'required' => false, 'type' => 'integer', 'validate_callback' => function($param) { return is_numeric($param) && $param > 0; } ], 'attachment_id' => [ 'required' => false, 'type' => 'integer', 'validate_callback' => function($param) { return is_numeric($param) && $param >= 0; } ] ] ] ); register_rest_route( 'aisk/v1', '/delete-pdf', array( 'methods' => 'POST', 'callback' => array( $this, 'delete_pdf_embeddings' ), 'permission_callback' => function ( $request ) { return current_user_can( 'manage_options' ) && wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ); }, ) ); register_rest_route( 'aisk/v1', '/pdf-queue-list', [ 'methods' => 'GET', 'callback' => [ $this, 'get_pdf_queue_list' ], 'permission_callback' => function ( $request ) { return current_user_can( 'upload_files' ) && wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ); }, ] ); } private function split_content($content, $max_chars = 8000) { // First, clean up the content by removing excessive whitespace and special characters $content = preg_replace('/\s+/', ' ', $content); // Keep punctuation that carries meaning: emails (@), SMS commands (#), times (:), URLs (/), prices and units $content = preg_replace('/[^\p{L}\p{N}\p{Sc}\s\-.,!?()\:;#\/@%+&|\'"°=*_]/u', '', $content); $content = trim($content); if (strlen($content) <= $max_chars) { return [$content]; } $chunks = []; $sentences = preg_split('/(?<=[.!?])\s+/', $content); $current_chunk = ''; foreach ($sentences as $sentence) { if (strlen($current_chunk . ' ' . $sentence) > $max_chars) { if (!empty($current_chunk)) { $chunks[] = trim($current_chunk); } $current_chunk = $sentence; } else { $current_chunk .= ('' === $current_chunk ? '' : ' ') . $sentence; } } if (!empty($current_chunk)) { $chunks[] = trim($current_chunk); } return $chunks; } /** * Fetch sitemap URLs from a website * * @param string $website_url Website URL * * @since 1.0.0 * * @return array Array of URLs from sitemap */ private function fetch_sitemap_urls( $website_url ) { // Normalize URL and ensure it ends with a slash $website_url = trailingslashit( esc_url( $website_url ) ); $urls = []; // Common sitemap locations $sitemap_locations = [ 'sitemap.xml', 'sitemap_index.xml', 'wp-sitemap.xml', 'robots.txt', ]; $sitemap_found = false; foreach ( $sitemap_locations as $location ) { $sitemap_url = $website_url . $location; $response = wp_remote_get($sitemap_url); if ( is_wp_error( $response ) ) { continue; } $body = wp_remote_retrieve_body( $response ); // If this is robots.txt, extract sitemap URL if ( $location === 'robots.txt' ) { preg_match_all('/Sitemap: (.*?)$/m', $body, $matches); if ( ! empty($matches[1] ) ) { foreach ( $matches[1] as $sitemap_url ) { $sitemap_urls = $this->parse_sitemap( trim( $sitemap_url ) ); if ( ! empty( $sitemap_urls ) ) { $sitemap_found = true; $urls = array_merge($urls, $sitemap_urls); } } } continue; } // Parse XML sitemap $sitemap_urls = $this->parse_sitemap( $sitemap_url ); if ( ! empty( $sitemap_urls ) ) { $sitemap_found = true; $urls = array_merge( $urls, $sitemap_urls ); } } // If no sitemap found, just use the given URL if ( ! $sitemap_found ) { $urls[] = $website_url; } return array_unique($urls); } /** * Parse sitemap XML and extract URLs * * @param string $sitemap_url Sitemap URL * * @since 1.0.0 * * @return array Array of URLs */ private function parse_sitemap( $sitemap_url ) { $urls = []; $response = wp_remote_get( $sitemap_url ); if ( is_wp_error( $response ) ) { return $urls; } $body = wp_remote_retrieve_body($response); libxml_use_internal_errors(true); $xml = simplexml_load_string($body); if ( ! $xml ) { return $urls; } // Handle sitemap index files if ( $xml->getName() === 'sitemapindex' ) { foreach ( $xml->sitemap as $sitemap ) { $urls = array_merge( $urls, $this->parse_sitemap( (string) $sitemap->loc ) ); } return $urls; } // Handle regular sitemaps foreach ( $xml->url as $url ) { $urls[] = (string) $url->loc; } return $urls; } /** * Process URLs in background to prevent timeouts */ public function process_url_background($job_id) { $job_data = get_option('aisk_url_processing_' . $job_id); if (!$job_data) { return; } $start_time = time(); try { // Update job status to processing $job_data['status'] = 'processing'; $job_data['updated_at'] = current_time('mysql'); update_option('aisk_url_processing_' . $job_id, $job_data); // Initialize the crawler components $content_fetcher = new AISK_URLContentFetcher(); $url_discoverer = new AISK_URLDiscoverer($content_fetcher); $content_processor = new AISK_ContentProcessor(); $crawler = new AISK_Crawler($content_fetcher, $url_discoverer, $content_processor); // Respect the website's robots.txt rules if (!$content_fetcher->is_allowed_by_robots($job_data['url'])) { $job_data['status'] = 'failed'; $job_data['error'] = 'This URL is disallowed by the website\'s robots.txt, so it cannot be crawled.'; $job_data['error_type'] = 'robots_disallowed'; $job_data['updated_at'] = current_time('mysql'); update_option('aisk_url_processing_' . $job_id, $job_data); return; } // Check for bot protection first $response = wp_remote_get($job_data['url'], $content_fetcher->get_request_args($job_data['url'])); if (is_wp_error($response)) { throw new Exception('Failed to fetch URL: ' . $response->get_error_message()); } if ($content_fetcher->detect_bot_protection( wp_remote_retrieve_body($response), wp_remote_retrieve_headers($response), wp_remote_retrieve_response_code($response) )) { // Update job status to failed with bot protection message $job_data['status'] = 'failed'; $job_data['error'] = 'Bot protection detected'; $job_data['error_type'] = 'bot_protection'; $job_data['updated_at'] = current_time('mysql'); update_option('aisk_url_processing_' . $job_id, $job_data); // Store the bot protection status in a separate option for future reference update_option('aisk_bot_protected_url_' . md5($job_data['url']), true); return; } // If no bot protection, proceed with normal processing $options = $job_data['options']; $options['max_depth'] = $this->crawl_max_depth; $options['max_pages'] = $this->crawl_max_pages; $options['request_timeout'] = $this->crawl_request_timeout; $options['max_job_time'] = $this->crawl_max_job_time; $results = $crawler->crawl($job_data['url'], $options); $save_success = $crawler->save_results($results); // Update job status; a job whose main URL failed is not completed $main_success = isset($results['main_url']['status']) && $results['main_url']['status'] === 'success'; $job_data['status'] = ($save_success && $main_success) ? 'completed' : 'failed'; if (!$main_success) { $job_data['error'] = !empty($results['errors'][0]) ? $results['errors'][0] : 'Failed to fetch content from this URL.'; } $job_data['results'] = $results; $job_data['updated_at'] = current_time('mysql'); update_option('aisk_url_processing_' . $job_id, $job_data); // Refresh help questions with the newly crawled content if ($save_success && class_exists('AISK_Help_Questions')) { $questions_result = AISK_Help_Questions::get_instance()->maybe_generate(); if (is_wp_error($questions_result) && defined('WP_DEBUG') && WP_DEBUG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging is properly guarded error_log('AISK help questions: ' . $questions_result->get_error_message()); } } } catch (Exception $e) { // Update job status to failed $job_data['status'] = 'failed'; $job_data['error'] = $e->getMessage(); $job_data['updated_at'] = current_time('mysql'); update_option('aisk_url_processing_' . $job_id, $job_data); } } /** * Handle URL processing with background support */ public function handle_url_processing($request) { $url = $request->get_param('website_url'); if (empty($url)) { return new WP_Error( 'missing_url', 'Website URL is required', ['status' => 400] ); } // Clear any earlier bot protection result so a manual retry is re-checked; // the background job flags the URL again if it is still protected $normalized_url = $this->normalize_url_for_job_id($url); delete_option('aisk_bot_protected_url_' . md5($normalized_url)); wp_cache_delete('aisk_bot_protected_' . md5($normalized_url), 'aisk_embeddings'); wp_cache_delete('aisk_job_status_' . md5($normalized_url), 'aisk_embeddings'); // Always use normalized URL for job ID $job_id = md5($normalized_url); // Overwrite any existing job for this URL delete_option('aisk_url_processing_' . $job_id); // Store the job data in WordPress options $job_data = [ 'url' => $normalized_url, // Store normalized URL (without trailing slash) 'normalized_url' => $normalized_url, 'options' => [ 'follow_links' => $request->get_param('follow_links') !== null ? filter_var($request->get_param('follow_links'), FILTER_VALIDATE_BOOLEAN) : true, 'include_selectors' => $request->get_param('include_selectors') ?? [], 'exclude_selectors' => $request->get_param('exclude_selectors') ?? [] ], 'status' => 'pending', 'progress' => 0, 'created_at' => current_time('mysql'), 'updated_at' => current_time('mysql') ]; update_option('aisk_url_processing_' . $job_id, $job_data); // Schedule the background processing wp_schedule_single_event(time() + 1, 'aisk_process_url_background', [$job_id]); // Return immediately with the job ID return new WP_REST_Response([ 'success' => true, 'job_id' => $job_id, 'message' => 'URL processing started in background' ], 200); } /** * Check job status */ public function check_url_status($request) { $job_id = $request->get_param('job_id'); if (empty($job_id)) { return new WP_Error( 'missing_job_id', 'Job ID is required', ['status' => 400] ); } $job_data = get_option('aisk_url_processing_' . $job_id); if (!$job_data) { return new WP_Error( 'invalid_job_id', 'Invalid job ID', ['status' => 404] ); } // If the job failed with bot protection, add a user-friendly message if ($job_data['status'] === 'failed' && isset($job_data['error_type']) && $job_data['error_type'] === 'bot_protection') { $job_data['user_message'] = 'This URL is protected against automated access. Please try a different URL or contact the website administrator.'; } return new WP_REST_Response($job_data, 200); } /** * Generate a user-friendly message based on crawling results * * @param array $results Crawling results * @return string User message */ private function generate_user_message($results) { $message = ''; if ($results['main_url']['status'] === 'success') { $message .= 'The main page has been successfully processed. '; } else { $message .= 'Failed to process the main page. '; } $successful_sub_urls = count(array_filter($results['subordinate_urls'], function($url) { return $url['status'] === 'success'; })); if ($successful_sub_urls > 0) { $message .= "Successfully processed {$successful_sub_urls} subordinate URLs. "; } else if (!empty($results['subordinate_urls'])) { $message .= 'No subordinate URLs could be processed. '; } if (!empty($results['warnings'])) { $message .= 'Some warnings were encountered during processing. '; } if (!empty($results['errors'])) { $message .= 'Some errors were encountered during processing. '; } return trim($message); } public function process_external_urls($urls, $parent_url = '', $include_selectors = [], $exclude_selectors = []) { global $wpdb; $processed = 0; $updated = 0; $errors = []; $table_name = $wpdb->prefix . 'aisk_embeddings'; foreach ($urls as $url) { try { // Pass the selectors to the get_url_content function $content = $this->get_url_content($url, $include_selectors, $exclude_selectors); if (!empty($content)) { $url_id = md5($url); $cache_key = 'aisk_embedding_external_url_' . $url; // Check if the exact URL already exists in the database $existing = wp_cache_get($cache_key, 'aisk_embeddings'); if (false === $existing) { // Use WordPress's transient API to check URL existence $existing = $this->check_url_exists($url); wp_cache_set($cache_key, $existing, 'aisk_embeddings', 3600); // Cache for 1 hour } $extra_data = [ 'crawled_url' => $url, 'parent_url' => $parent_url, ]; if ($existing) { // If URL exists, create a new entry instead of updating $new_url_id = md5($url . time()); // Generate a unique ID for the new entry $this->store_embedding('external_url', $new_url_id, $content, $extra_data); $processed++; } else { // If the URL is completely new, insert it $this->store_embedding('external_url', $url_id, $content, $extra_data); $processed++; } } } catch (Exception $e) { $errors[] = "Error processing URL: {$url} - " . $e->getMessage(); } } return [ 'success' => true, 'processed' => $processed, 'updated' => $updated, 'total' => $processed + $updated, 'errors' => $errors, ]; } /** * Check status of URLs being processed * * @param WP_REST_Request $request The REST request object * @return array Status information for the requested URLs */ public function check_urls_status($request) { global $wpdb; $urls = $request->get_param('urls'); if (empty($urls) || !is_array($urls)) { return [ 'success' => false, 'message' => 'No URLs provided to check' ]; } $statuses = []; foreach ($urls as $url) { // Normalize URL for consistent job ID $normalized_url = $this->normalize_url_for_job_id($url); // Check if URL is bot protected $bot_protected_cache_key = 'aisk_bot_protected_' . md5($normalized_url); $is_bot_protected = wp_cache_get($bot_protected_cache_key, 'aisk_embeddings'); if (false === $is_bot_protected) { $is_bot_protected = get_option('aisk_bot_protected_url_' . md5($normalized_url)); wp_cache_set($bot_protected_cache_key, $is_bot_protected, 'aisk_embeddings', 3600); } if ($is_bot_protected) { $statuses[$url] = [ 'status' => 'bot_protected', 'error_type' => 'bot_protection', 'user_message' => 'This URL is protected against automated access. Please try a different URL or contact the website administrator.' ]; continue; } // Check job status in options table using normalized URL $job_id = md5($normalized_url); $job_cache_key = 'aisk_job_status_' . $job_id; $job_data = wp_cache_get($job_cache_key, 'aisk_embeddings'); if (false === $job_data) { $job_data = get_option('aisk_url_processing_' . $job_id); wp_cache_set($job_cache_key, $job_data, 'aisk_embeddings', 3600); } $job_status = $job_data['status'] ?? null; // Check for main URL embedding using normalized URL $cache_key = 'aisk_embedding_count_' . md5($normalized_url); $main_embedding = wp_cache_get($cache_key, 'aisk_embeddings'); if (false === $main_embedding) { // @codingStandardsIgnoreStart $main_embedding = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}aisk_embeddings WHERE content_type = 'external_url' AND crawled_url = %s", $normalized_url ) ); // @codingStandardsIgnoreEnd wp_cache_set($cache_key, $main_embedding, 'aisk_embeddings', 3600); } if ($job_status === 'completed') { if ($main_embedding > 0) { $statuses[$url] = 'processed'; } else { // No embedding, show user message if available $user_message_cache_key = 'aisk_user_message_' . md5($normalized_url); $user_message = wp_cache_get($user_message_cache_key, 'aisk_embeddings'); if (false === $user_message) { $user_message = get_option('aisk_url_user_message_' . md5($normalized_url)); wp_cache_set($user_message_cache_key, $user_message, 'aisk_embeddings', 3600); } $statuses[$url] = [ 'status' => 'no_content', 'user_message' => $user_message ]; } } else if ($job_status === 'failed') { $user_message = $job_data['error'] ?? get_option('aisk_url_user_message_' . md5($normalized_url)); $statuses[$url] = [ 'status' => 'failed', 'user_message' => $user_message ?: 'Job failed for this URL.' ]; continue; } else { $statuses[$url] = [ 'status' => 'processing', 'user_message' => '' ]; } } return [ 'success' => true, 'statuses' => $statuses ]; } private function store_embedding( $content_type, $content_id, $content, $extra_data = [] ) { global $wpdb; $chunks = $this->split_content($content); $success = false; $cache_key = 'aisk_embedding_' . $content_type . '_' . $content_id; foreach ( $chunks as $chunk ) { try { $embedding = $this->generate_embedding($chunk); if ( $embedding ) { $data = [ 'content_type' => $content_type, 'content_id' => $content_id, 'embedding' => $embedding, 'content_chunk' => $chunk, ]; $format = [ '%s', '%d', '%s', '%s' ]; // For external URLs, add additional fields if provided if ( $content_type === 'external_url' && ! empty( $extra_data ) ) { if ( isset( $extra_data['parent_url'] ) ) { $data['parent_url'] = $extra_data['parent_url']; $format[] = '%s'; } if ( isset( $extra_data['crawled_url'] ) ) { $data['crawled_url'] = $extra_data['crawled_url']; $format[] = '%s'; } } // @codingStandardsIgnoreStart $result = $wpdb->insert("{$wpdb->prefix}aisk_embeddings", $data, $format); // @codingStandardsIgnoreEnd if ( $result !== false ) { $success = true; // Clear the cache after successful insertion wp_cache_delete($cache_key, 'aisk_embeddings'); wp_cache_delete('aisk_embeddings_type_' . $content_type, 'aisk_embeddings'); } } } catch (Exception $e) { // Error handling without logging } } return $success; } private function update_embedding( $content_type, $content_id, $content, $extra_data = [] ) { global $wpdb; $cache_key = 'aisk_embedding_' . $content_type . '_' . $content_id; $embedding = $this->generate_embedding($content); if ( ! $embedding ) { return false; } $data = [ 'content_chunk' => $content, 'embedding' => $embedding, 'updated_at' => current_time('mysql'), ]; if ( $content_type === 'external_url' && ! empty( $extra_data ) ) { if ( isset( $extra_data['parent_url'] ) ) { $data['parent_url'] = $extra_data['parent_url']; } if ( isset( $extra_data['crawled_url'] ) ) { $data['crawled_url'] = $extra_data['crawled_url']; } } // @codingStandardsIgnoreStart $result = $wpdb->update( "{$wpdb->prefix}aisk_embeddings", $data, [ 'content_type' => $content_type, 'content_id' => $content_id, ] ); // @codingStandardsIgnoreEnd if ($result !== false) { // Clear the cache after successful update wp_cache_delete($cache_key, 'aisk_embeddings'); wp_cache_delete('aisk_embeddings_type_' . $content_type, 'aisk_embeddings'); } return $result; } /** * Function to crawl a website with improved depth, path filtering, and error handling * * @param string $starting_url Starting URL to crawl * @param int $max_depth Maximum crawl depth * @param int $max_pages Maximum number of pages to crawl * @param array $include_patterns Patterns to include * @param array $exclude_patterns Patterns to exclude * @return array Array of discovered URLs */ private function crawl_website($starting_url, $max_depth = 2, $max_pages = 100, $include_patterns = [], $exclude_patterns = []) { $visited = []; $to_visit = [$starting_url]; $processed = 0; $last_request_time = 0; $rate_limit = 1; // Minimum seconds between requests while (!empty($to_visit) && $processed < $max_pages) { $current_url = array_shift($to_visit); if (isset($visited[$current_url])) { continue; } // Rate limiting $current_time = microtime(true); $time_since_last_request = $current_time - $last_request_time; if ($time_since_last_request < $rate_limit) { usleep(($rate_limit - $time_since_last_request) * 1000000); } $content = $this->get_url_content($current_url); if ($content === false) { continue; } $visited[$current_url] = true; $processed++; // Store the content $this->store_embedding( 'webpage', md5($current_url), $content, ['url' => $current_url] ); // Extract and queue new URLs if ($processed < $max_pages) { $links = $this->extract_links($content, $starting_url); foreach ($links as $link) { if (!isset($visited[$link]) && $this->should_process_url($link, $include_patterns, $exclude_patterns)) { $to_visit[] = $link; } } } $last_request_time = microtime(true); } return array_keys($visited); } /** * Check if a URL should be processed based on include/exclude patterns * * @param string $url URL to check * @param array $include_patterns Patterns to include * @param array $exclude_patterns Patterns to exclude * @return bool True if URL should be processed */ private function should_process_url($url, $include_patterns = [], $exclude_patterns = []) { $path = wp_parse_url($url, PHP_URL_PATH); if (!$path) $path = '/'; // Check exclude patterns first if (!empty($exclude_patterns)) { foreach ($exclude_patterns as $pattern) { // Convert wildcard pattern to regex $regex = $this->wildcard_to_regex($pattern); if (preg_match($regex, $path)) { return false; } } } // If include patterns are empty, allow all URLs that weren't excluded if (empty($include_patterns)) { return true; } // If include patterns are specified, URL must match at least one foreach ($include_patterns as $pattern) { $regex = $this->wildcard_to_regex($pattern); if (preg_match($regex, $path)) { return true; } } return false; } /** * Convert a wildcard pattern to a regex pattern * * @param string $pattern Wildcard pattern (e.g., /blog/*) * @return string Regex pattern */ private function wildcard_to_regex($pattern) { $pattern = preg_quote($pattern, '/'); // Convert wildcard * to regex .* $pattern = str_replace('\*', '.*', $pattern); return '/^' . $pattern . '$/i'; } /** * Normalize a URL, handling relative URLs * * @param string $href URL or relative path * @param string $base_url Base URL for relative paths * @param string $domain Domain of the site being crawled * @return string|false Normalized URL or false if invalid */ private function normalize_url($href, $base_url, $domain) { // Handle mailto, tel, javascript links if (preg_match('/^(mailto:|tel:|javascript:|#)/i', $href)) { return false; } // For protocol-relative URLs (//example.com) if (substr($href, 0, 2) === '//') { $base_protocol = wp_parse_url($base_url, PHP_URL_SCHEME); return $base_protocol . ':' . $href; } // Handle absolute URLs if (preg_match('/^https?:\/\//i', $href)) { // Only return URLs from the same domain $href_domain = wp_parse_url($href, PHP_URL_HOST); if ($href_domain !== $domain) { return false; } return $href; } // Handle absolute paths if (substr($href, 0, 1) === '/') { $base_parts = wp_parse_url($base_url); $scheme = isset($base_parts['scheme']) ? $base_parts['scheme'] : 'https'; return $scheme . '://' . $domain . $href; } // Handle relative paths $base_parts = wp_parse_url($base_url); $path = isset($base_parts['path']) ? $base_parts['path'] : ''; $path = preg_replace('/\/[^\/]*$/', '/', $path); // Remove file part from path $scheme = isset($base_parts['scheme']) ? $base_parts['scheme'] : 'https'; return $scheme . '://' . $domain . rtrim($path, '/') . '/' . ltrim($href, '/'); } private function extract_links($content, $base_url) { $links = []; $doc = new DOMDocument(); libxml_use_internal_errors(true); @$doc->loadHTML(mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8')); $xpath = new DOMXPath($doc); $elements = $xpath->query('//a'); foreach ($elements as $element) { /** @var DOMElement $element */ $href = $element->getAttribute('href'); if (empty($href)) { continue; } // Skip page anchors, javascript:, mailto:, tel:, etc. if (preg_match('/^(#|javascript:|mailto:|tel:)/i', $href)) { continue; } // Skip URLs with hash fragments if (strpos($href, '#') !== false) { $href = preg_replace('/#.*$/', '', $href); if (empty($href)) { continue; } } // Convert relative URLs to absolute $domain = wp_parse_url($base_url, PHP_URL_HOST); $absolute_url = $this->normalize_url($href, $base_url, $domain); if ($absolute_url) { $links[] = $absolute_url; } } return array_unique($links); } private function get_url_content($url, $include_selectors = [], $exclude_selectors = []) { $content_fetcher = new AISK_URLContentFetcher(); if (!$content_fetcher->is_allowed_by_robots($url)) { return false; } $response = wp_remote_get($url, $content_fetcher->get_request_args($url)); if (is_wp_error($response)) { return false; } $status_code = wp_remote_retrieve_response_code($response); if ($status_code !== 200) { return false; } $content_type = wp_remote_retrieve_header($response, 'content-type'); if (!preg_match('/(text\/html|application\/xhtml\+xml)/i', $content_type)) { return false; } $html = wp_remote_retrieve_body($response); return $this->process_html_content($html, $url, $include_selectors, $exclude_selectors); } /** * Process raw HTML to extract meaningful content * * @param string $html Raw HTML content * @param string $url Original URL * @return string Processed content */ private function process_html_content($html, $url, $include_selectors = [], $exclude_selectors = []) { // Fix encoding issues if (!mb_check_encoding($html, 'UTF-8')) { $html = mb_convert_encoding($html, 'UTF-8', mb_detect_encoding($html, 'UTF-8, ISO-8859-1', true)); } // First, try to extract metadata $title = ''; if (preg_match('/]*>(.*?)<\/title>/is', $html, $matches)) { $title = trim(wp_strip_all_tags($matches[1])); } $description = ''; if (preg_match('/]*name=["\']description["\'][^>]*content=["\']([^"\']+)["\'][^>]*>/is', $html, $matches) || preg_match('/]*content=["\']([^"\']+)["\'][^>]*name=["\']description["\'][^>]*>/is', $html, $matches)) { $description = trim($matches[1]); } $metadata = ""; if (!empty($title)) { $metadata .= "Title: $title\n\n"; } // Create DOM Document for parsing $doc = new DOMDocument('1.0', 'UTF-8'); libxml_use_internal_errors(true); // Suppress warnings for invalid HTML @$doc->loadHTML('' . $html); libxml_clear_errors(); // Remove script, style, svg, and other non-content elements $this->remove_elements_by_tag($doc, 'script'); $this->remove_elements_by_tag($doc, 'style'); $this->remove_elements_by_tag($doc, 'svg'); $this->remove_elements_by_tag($doc, 'noscript'); $this->remove_elements_by_tag($doc, 'iframe'); // Initialize XPath to work with selectors $xpath = new DOMXPath($doc); // Handle exclude selectors first - remove matched elements before content extraction if (!empty($exclude_selectors)) { foreach ($exclude_selectors as $selector) { $this->remove_elements_by_selector($doc, $xpath, $selector); } } // If include selectors are provided, extract only the content from those elements $filteredContent = ''; if (!empty($include_selectors)) { foreach ($include_selectors as $selector) { $elements = $this->get_elements_by_selector($xpath, $selector); foreach ($elements as $element) { // Clone the node to avoid modification issues $clone = $element->cloneNode(true); $innerDoc = new DOMDocument('1.0', 'UTF-8'); $innerDoc->appendChild($innerDoc->importNode($clone, true)); // Clean unwanted elements from this specific element too $this->remove_elements_by_tag($innerDoc, 'script'); $this->remove_elements_by_tag($innerDoc, 'style'); // Get text content from the cleaned element $filteredContent .= trim($innerDoc->textContent) . "\n\n"; } } // Clean the filtered content $content = $this->clean_content($filteredContent); } else { // If no include selectors, extract the complete text content from the body (with excluded elements already removed) $body = $doc->getElementsByTagName('body'); if ($body->length > 0) { $raw_content = $body->item(0)->textContent; $content = $this->clean_content($raw_content); } else { $content = ''; } } // Also extract content via specific approaches if we didn't use include selectors $gutenberg_content = ''; $structured_content = ''; if (empty($include_selectors)) { $gutenberg_content = $this->extract_gutenberg_content($html); $structured_content = $this->extract_structured_content($doc); } // Combine content from different extraction methods $final_content = $metadata; if (!empty($content)) { $final_content .= "Content:\n" . $content . "\n\n"; } if (!empty($gutenberg_content) && strpos($content, $gutenberg_content) === false) { $final_content .= "Additional Gutenberg Content:\n" . $gutenberg_content . "\n\n"; } if (!empty($structured_content) && strpos($content, $structured_content) === false) { $final_content .= "Additional Structured Content:\n" . $structured_content . "\n\n"; } $final_content .= "Source URL: " . $url; return $final_content; } /** * Helper function to remove elements by CSS selector */ private function remove_elements_by_selector($doc, $xpath, $selector) { // Parse the selector to handle different types (tag, class, id) $selector = trim($selector); $elements = $this->get_elements_by_selector($xpath, $selector); // Remove from the end to avoid issues with changing node lists foreach ($elements as $element) { if ($element && $element->parentNode) { $element->parentNode->removeChild($element); } } } /** * Helper function to get elements by CSS selector */ private function get_elements_by_selector($xpath, $selector) { $selector = trim($selector); $elements = array(); // Handle simple tag name selector (e.g., "div", "header") if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $selector)) { $nodeList = $xpath->query("//{$selector}"); if ($nodeList) { foreach ($nodeList as $node) { $elements[] = $node; } } return $elements; } // Handle class selector (e.g., ".content", ".header") if (substr($selector, 0, 1) === '.') { $class = substr($selector, 1); $nodeList = $xpath->query("//*[contains(concat(' ', normalize-space(@class), ' '), ' {$class} ')]"); if ($nodeList) { foreach ($nodeList as $node) { $elements[] = $node; } } return $elements; } // Handle ID selector (e.g., "#main", "#content") if (substr($selector, 0, 1) === '#') { $id = substr($selector, 1); $nodeList = $xpath->query("//*[@id='{$id}']"); if ($nodeList) { foreach ($nodeList as $node) { $elements[] = $node; } } return $elements; } // For more complex selectors, we'd need a more robust parser // This simplistic implementation supports only tag, class, and ID selectors return $elements; } /** * Remove elements by tag name from DOMDocument */ private function remove_elements_by_tag($doc, $tag_name) { $elements = $doc->getElementsByTagName($tag_name); $total = $elements->length; // Remove from the end to avoid issues with changing node lists for ($i = $total - 1; $i >= 0; $i--) { $element = $elements->item($i); if ($element && $element->parentNode) { $element->parentNode->removeChild($element); } } } /** * Extract content specifically from Gutenberg blocks */ private function extract_gutenberg_content($html) { $content = ''; // Look for Gutenberg block content in JSON format if (preg_match('/