*/ private const PILLAR_WEIGHTS = array( 'schema' => 0.25, 'feeds' => 0.20, 'content' => 0.20, 'crawlers' => 0.15, 'technical' => 0.10, 'attribution' => 0.10, ); /** * Old-style sub-score weights for backward compatibility (must sum to 100). * * @var array */ private const WEIGHTS = array( 'schema' => 35, 'content' => 30, 'technical' => 20, 'identifiers' => 15, ); /** * WordPress database instance. * * @var \wpdb */ private \wpdb $wpdb; /** * Service container. * * @var ServiceContainer */ private ServiceContainer $container; /** * Constructor. * * @param \wpdb $wpdb WordPress database instance. * @param ServiceContainer $container Service container. */ public function __construct( \wpdb $wpdb, ServiceContainer $container ) { $this->wpdb = $wpdb; $this->container = $container; } /** * Calculate the store-level readiness score. * * @param bool $force_refresh Whether to bypass cache. * * @return array Store score breakdown. */ public function calculate_store_score( bool $force_refresh = false ): array { if ( ! $force_refresh ) { $cached = get_transient( self::CACHE_KEY ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } } $pillar_scores = array( 'schema' => $this->score_schema_pillar(), 'feeds' => $this->score_feeds_pillar(), 'content' => $this->score_content_pillar(), 'crawlers' => $this->score_crawlers_pillar(), 'technical' => $this->score_technical_pillar(), 'attribution' => $this->is_business_tier() ? $this->score_attribution_pillar() : 50, ); // Compute weighted overall score. $overall = 0.0; foreach ( self::PILLAR_WEIGHTS as $key => $weight ) { $overall += $pillar_scores[ $key ] * $weight; } $overall = (int) round( $overall ); // Trend calculation. $history = get_option( 'aicommerce_score_history', array() ); $today = gmdate( 'Y-m-d' ); $history[ $today ] = $overall; // Find score from 30 days ago. $thirty_days_ago = gmdate( 'Y-m-d', strtotime( '-30 days' ) ); $old_score = 0; foreach ( $history as $date => $score ) { if ( $date <= $thirty_days_ago ) { $old_score = (int) $score; } } $trend = $overall - $old_score; // Prune history older than 90 days. $ninety_days_ago = gmdate( 'Y-m-d', strtotime( '-90 days' ) ); foreach ( $history as $date => $score ) { if ( $date < $ninety_days_ago ) { unset( $history[ $date ] ); } } update_option( 'aicommerce_score_history', $history, false ); $pillar_labels = array( 'schema' => __( 'Schema Completeness', 'ai-discoverability-kit' ), 'feeds' => __( 'Feed Health', 'ai-discoverability-kit' ), 'content' => __( 'Content Quality', 'ai-discoverability-kit' ), 'crawlers' => __( 'Crawler Access', 'ai-discoverability-kit' ), 'technical' => __( 'Technical Foundation', 'ai-discoverability-kit' ), 'attribution' => __( 'AI Attribution', 'ai-discoverability-kit' ), ); $pillars = array(); foreach ( self::PILLAR_WEIGHTS as $key => $weight ) { $pillars[ $key ] = array( 'score' => $pillar_scores[ $key ], 'weight' => $weight, 'label' => $pillar_labels[ $key ], ); } $pillars['attribution']['requires_business'] = true; $result = array( 'overall' => $overall, 'pillars' => $pillars, 'trend' => $trend, 'calculated_at' => gmdate( 'c' ), 'next_refresh' => gmdate( 'c', time() + self::CACHE_TTL ), ); set_transient( self::CACHE_KEY, $result, self::CACHE_TTL ); return $result; } /** * Score the Schema Completeness pillar. * * @return int Score 0-100. */ private function score_schema_pillar(): int { $points = 0; // +20: Schema enabled. if ( get_option( 'aicommerce_schema_enabled', true ) ) { $points += 20; } // +20: Any product has Offer schema. $table = $this->wpdb->prefix . 'aicommerce_readiness_cache'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $offer_count = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE schema_score > 0" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $offer_count > 0 ) { $points += 20; } // +15: Reviews exist. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $review_count = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$this->wpdb->comments} WHERE comment_type = 'review'" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $review_count > 0 ) { $points += 15; } // +15: Any product has active FAQs. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $faq_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s", '_aicommerce_faqs_active', '1' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $faq_count > 0 ) { $points += 15; } // +15: BreadcrumbList (auto-generated when schema is enabled). if ( get_option( 'aicommerce_schema_enabled', true ) ) { $points += 15; } // +15: Default brand is set or any product has brand meta. $default_brand = get_option( 'aicommerce_default_brand', '' ); if ( ! empty( $default_brand ) ) { $points += 15; } else { // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $brand_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->wpdb->postmeta} WHERE meta_key = %s AND meta_value != ''", '_aicommerce_brand' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $brand_count > 0 ) { $points += 15; } } return $points; } /** * Score the Feed Health pillar. * * @return int Score 0-100. */ private function score_feeds_pillar(): int { $points = 0; $feeds_table = $this->wpdb->prefix . 'aicommerce_feeds'; // +30: Google feed complete. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $google_feed = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$feeds_table} WHERE feed_type = %s AND status = %s", 'google', 'complete' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $google_feed > 0 ) { $points += 30; } // +25: OpenAI feed complete. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $openai_feed = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$feeds_table} WHERE feed_type = %s AND status = %s", 'openai', 'complete' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $openai_feed > 0 ) { $points += 25; } // +15: Any feed regenerated in last 24 hours. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $recent_feed = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$feeds_table} WHERE generated_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $recent_feed > 0 ) { $points += 15; } // +20: ai-commerce.json manifest accessible. $manifest_ok = get_transient( 'aicommerce_manifest_ok' ); if ( false === $manifest_ok ) { $response = wp_remote_get( home_url( '/.well-known/ai-commerce.json' ), array( 'timeout' => 5 ) ); $manifest_ok = ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) ? 'yes' : 'no'; set_transient( 'aicommerce_manifest_ok', $manifest_ok, 6 * HOUR_IN_SECONDS ); } if ( 'yes' === $manifest_ok ) { $points += 20; } // +10: GTIN coverage. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $gtin_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->wpdb->postmeta} WHERE meta_key = %s AND meta_value != ''", '_aicommerce_gtin' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $gtin_count > 0 ) { $points += 10; } return $points; } /** * Score the Content Quality pillar. * * @return int Score 0-100. */ private function score_content_pillar(): int { $cached = get_transient( 'aicommerce_content_quality' ); if ( false !== $cached && is_int( $cached ) ) { return $cached; } $points = 0; // +20: Average description > 100 words (sample 50 products). // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $sample = $this->wpdb->get_results( "SELECT post_content FROM {$this->wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' ORDER BY RAND() LIMIT 50" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( ! empty( $sample ) ) { $total_words = 0; foreach ( $sample as $row ) { $text = wp_strip_all_tags( $row->post_content ); $total_words += str_word_count( $text ); } $avg_words = $total_words / count( $sample ); if ( $avg_words > 100 ) { $points += 20; } } // Total published products count. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $total_products = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$this->wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish'" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $total_products > 0 ) { // +30: % products with AI-optimized description active. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $ai_desc_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(DISTINCT pm.post_id) FROM {$this->wpdb->postmeta} pm INNER JOIN {$this->wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value = %s AND p.post_type = 'product' AND p.post_status = 'publish'", '_aicommerce_description_active', '1' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $points += (int) round( ( $ai_desc_count / $total_products ) * 30 ); // +25: % products with active FAQs. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $faq_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(DISTINCT pm.post_id) FROM {$this->wpdb->postmeta} pm INNER JOIN {$this->wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value = %s AND p.post_type = 'product' AND p.post_status = 'publish'", '_aicommerce_faqs_active', '1' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $points += (int) round( ( $faq_count / $total_products ) * 25 ); // +25: % product images with alt text meta. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $alt_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(DISTINCT pm.post_id) FROM {$this->wpdb->postmeta} pm INNER JOIN {$this->wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = 'product' AND p.post_status = 'publish'", '_aicommerce_alt_text' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $points += (int) round( ( $alt_count / $total_products ) * 25 ); } set_transient( 'aicommerce_content_quality', $points, HOUR_IN_SECONDS ); return $points; } /** * Score the Crawler Access pillar. * * @return int Score 0-100. */ private function score_crawlers_pillar(): int { $points = 0; $bot_rules = get_option( 'aicommerce_bot_rules', array() ); $blocked = is_array( $bot_rules ) ? $bot_rules : array(); // +30: GPTBot allowed. if ( ! in_array( 'GPTBot', $blocked, true ) ) { $points += 30; } // +25: Google-Extended allowed. if ( ! in_array( 'Google-Extended', $blocked, true ) ) { $points += 25; } // +25: PerplexityBot allowed. if ( ! in_array( 'PerplexityBot', $blocked, true ) ) { $points += 25; } // +20: GPTBot visited in last 14 days. $crawler_table = $this->wpdb->prefix . 'aicommerce_crawler_log'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $gptbot_visits = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$crawler_table} WHERE bot_name = %s AND request_time > DATE_SUB(NOW(), INTERVAL 14 DAY)", 'GPTBot' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $gptbot_visits > 0 ) { $points += 20; } return $points; } /** * Score the Technical Foundation pillar. * * @return int Score 0-100. */ private function score_technical_pillar(): int { $points = 0; // +35: HTTPS. if ( is_ssl() || 0 === strpos( home_url(), 'https' ) ) { $points += 35; } // +30: Sitemap present. $sitemap_ok = get_transient( 'aicommerce_sitemap_ok' ); if ( false === $sitemap_ok ) { $response = wp_remote_head( home_url( '/sitemap.xml' ), array( 'timeout' => 5 ) ); $sitemap_ok = ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) ? 'yes' : 'no'; set_transient( 'aicommerce_sitemap_ok', $sitemap_ok, DAY_IN_SECONDS ); } if ( 'yes' === $sitemap_ok ) { $points += 30; } // +35: No schema conflicts (conflict suppression enabled). if ( get_option( 'aicommerce_conflict_suppress', true ) ) { $points += 35; } return $points; } /** * Score the AI Attribution pillar. * * @return int Score 0-100. */ private function score_attribution_pillar(): int { $points = 0; $attribution_table = $this->wpdb->prefix . 'aicommerce_attribution'; // +40: Has AI referrer traffic in last 30 days. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $traffic_count = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$attribution_table} WHERE attributed_at > DATE_SUB(NOW(), INTERVAL 30 DAY)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $traffic_count > 0 ) { $points += 40; } // +30: AI referrer traffic growing (this month vs last month). $this_month_start = gmdate( 'Y-m-01' ); $last_month_start = gmdate( 'Y-m-01', strtotime( '-1 month' ) ); // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this_month_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$attribution_table} WHERE attributed_at >= %s", $this_month_start ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $last_month_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$attribution_table} WHERE attributed_at >= %s AND attributed_at < %s", $last_month_start, $this_month_start ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $this_month_count > $last_month_count ) { $points += 30; } // +30: At least 1 AI-attributed order in last 30 days. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $order_count = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$attribution_table} WHERE order_id > 0 AND attributed_at > DATE_SUB(NOW(), INTERVAL 30 DAY)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( $order_count > 0 ) { $points += 30; } return $points; } /** * Check if the current site is on the business tier. * * @return bool True if business tier. */ private function is_business_tier(): bool { return function_exists( 'aicommerce_is_business' ) && aicommerce_is_business(); } /** * Calculate a simple per-product readiness score. * * @param int $product_id The product ID. * * @return int Score 0-100. */ public function calculate_product_score( int $product_id ): int { $product = wc_get_product( $product_id ); if ( ! $product ) { return 0; } $points = 0; // +30: Has Product schema (schema enabled). if ( get_option( 'aicommerce_schema_enabled', true ) ) { $points += 30; } // +25: Has Offer (product has price). if ( '' !== $product->get_price() ) { $points += 25; } // +15: Description > 50 words. $description = wp_strip_all_tags( $product->get_description() ); if ( str_word_count( $description ) > 50 ) { $points += 15; } // +20: Has AI-optimized description active. if ( '1' === $product->get_meta( '_aicommerce_description_active' ) ) { $points += 20; } // +10: Has FAQs active. if ( '1' === $product->get_meta( '_aicommerce_faqs_active' ) ) { $points += 10; } return min( 100, $points ); } /** * Get a list of actionable issues sorted by impact. * * @return array> Sorted issues. */ public function get_issues(): array { $issues = array(); // 1. No Google feed. $feeds_table = $this->wpdb->prefix . 'aicommerce_feeds'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $google_feed = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$feeds_table} WHERE feed_type = %s AND status = %s", 'google', 'complete' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( 0 === $google_feed ) { $issues[] = array( 'id' => 'no_google_feed', 'pillar' => 'feeds', 'title' => __( 'No Google Product Feed', 'ai-discoverability-kit' ), 'description' => __( 'Generate a Google Merchant feed to surface your products in AI-powered shopping results.', 'ai-discoverability-kit' ), 'impact' => 'high', 'impact_score' => 90, 'estimated_score_gain' => 7, 'fix_url' => admin_url( 'admin.php?page=tyralix-aicommerce-settings' ), 'auto_fixable' => false, ); } // 2. GPTBot blocked. $bot_rules = get_option( 'aicommerce_bot_rules', array() ); $blocked = is_array( $bot_rules ) ? $bot_rules : array(); if ( in_array( 'GPTBot', $blocked, true ) ) { $issues[] = array( 'id' => 'gptbot_blocked', 'pillar' => 'crawlers', 'title' => __( 'GPTBot Is Blocked', 'ai-discoverability-kit' ), 'description' => __( 'GPTBot is currently blocked from crawling your store, preventing ChatGPT from discovering your products.', 'ai-discoverability-kit' ), 'impact' => 'high', 'impact_score' => 85, 'estimated_score_gain' => 5, 'fix_url' => null, 'auto_fixable' => true, ); } // 3. Schema conflict suppression disabled. if ( ! get_option( 'aicommerce_conflict_suppress', true ) ) { $issues[] = array( 'id' => 'schema_conflict', 'pillar' => 'schema', 'title' => __( 'Schema Conflicts Detected', 'ai-discoverability-kit' ), 'description' => __( 'Duplicate schema markup from other plugins may confuse AI crawlers. Enable conflict suppression to resolve.', 'ai-discoverability-kit' ), 'impact' => 'high', 'impact_score' => 80, 'estimated_score_gain' => 6, 'fix_url' => null, 'auto_fixable' => true, ); } // 4. No AI content. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $ai_content_count = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$this->wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s", '_aicommerce_description_active', '1' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( 0 === $ai_content_count ) { $issues[] = array( 'id' => 'no_ai_content', 'pillar' => 'content', 'title' => __( 'No AI-Optimized Content', 'ai-discoverability-kit' ), 'description' => __( 'None of your products have AI-optimized descriptions. Use the bulk optimizer to enhance your content.', 'ai-discoverability-kit' ), 'impact' => 'medium', 'impact_score' => 60, 'estimated_score_gain' => 4, 'fix_url' => admin_url( 'admin.php?page=tyralix-aicommerce-bulk-optimizer' ), 'auto_fixable' => false, ); } // 5. HTTP store (not HTTPS). if ( ! is_ssl() && 0 !== strpos( home_url(), 'https' ) ) { $issues[] = array( 'id' => 'http_store', 'pillar' => 'technical', 'title' => __( 'Store Not Using HTTPS', 'ai-discoverability-kit' ), 'description' => __( 'Your store is not served over HTTPS. AI crawlers and search engines prefer secure sites.', 'ai-discoverability-kit' ), 'impact' => 'high', 'impact_score' => 75, 'estimated_score_gain' => 4, 'fix_url' => null, 'auto_fixable' => false, ); } // 6. GPTBot no recent visit. $crawler_table = $this->wpdb->prefix . 'aicommerce_crawler_log'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $gptbot_visits = (int) $this->wpdb->get_var( $this->wpdb->prepare( "SELECT COUNT(*) FROM {$crawler_table} WHERE bot_name = %s AND request_time > DATE_SUB(NOW(), INTERVAL 14 DAY)", 'GPTBot' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( 0 === $gptbot_visits ) { $issues[] = array( 'id' => 'gptbot_no_recent_visit', 'pillar' => 'crawlers', 'title' => __( 'No Recent GPTBot Visits', 'ai-discoverability-kit' ), 'description' => __( 'GPTBot has not visited your store in the last 14 days. Ensure your robots.txt allows access.', 'ai-discoverability-kit' ), 'impact' => 'medium', 'impact_score' => 50, 'estimated_score_gain' => 2, 'fix_url' => null, 'auto_fixable' => false, ); } // 7. No feeds regenerated recently. // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $recent_feed = (int) $this->wpdb->get_var( "SELECT COUNT(*) FROM {$feeds_table} WHERE generated_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( 0 === $recent_feed ) { $issues[] = array( 'id' => 'no_feeds_recent', 'pillar' => 'feeds', 'title' => __( 'Feeds Not Recently Updated', 'ai-discoverability-kit' ), 'description' => __( 'No product feed has been regenerated in the last 24 hours. Stale feeds may contain outdated data.', 'ai-discoverability-kit' ), 'impact' => 'medium', 'impact_score' => 55, 'estimated_score_gain' => 3, 'fix_url' => admin_url( 'admin.php?page=tyralix-aicommerce-settings' ), 'auto_fixable' => false, ); } // 8. No brand set. $default_brand = get_option( 'aicommerce_default_brand', '' ); if ( empty( $default_brand ) ) { $issues[] = array( 'id' => 'no_brand_set', 'pillar' => 'schema', 'title' => __( 'No Default Brand Set', 'ai-discoverability-kit' ), 'description' => __( 'Setting a default brand improves schema completeness for products without individual brand data.', 'ai-discoverability-kit' ), 'impact' => 'medium', 'impact_score' => 45, 'estimated_score_gain' => 2, 'fix_url' => admin_url( 'admin.php?page=tyralix-aicommerce-settings' ), 'auto_fixable' => false, ); } // Sort by impact_score descending. usort( $issues, static function ( array $a, array $b ): int { return $b['impact_score'] <=> $a['impact_score']; } ); return $issues; } // ------------------------------------------------------------------------- // Backward compatibility methods. // ------------------------------------------------------------------------- /** * Calculate the old-style per-product readiness score (4-pillar structure). * * Kept for backward compatibility with MetaBox and REST endpoints. * * @param \WC_Product $product The WooCommerce product. * * @return array Score breakdown. */ public function calculate( \WC_Product $product ): array { /** @var ProductSchema $product_schema */ $product_schema = $this->container->get( ProductSchema::class ); /** @var Validator $validator */ $validator = $this->container->get( Validator::class ); $sub_scores = array( 'schema' => $this->score_schema( $product, $product_schema, $validator ), 'content' => $this->score_content( $product ), 'technical' => $this->score_technical_product( $product ), 'identifiers' => $this->score_identifiers( $product ), ); // Weighted overall score. $overall = 0; foreach ( $sub_scores as $key => $sub ) { $overall += ( $sub['score'] / 100 ) * self::WEIGHTS[ $key ]; } $overall = (int) round( $overall ); $result = array( 'overall' => $overall, 'grade' => $this->score_to_grade( $overall ), 'sub_scores' => $sub_scores, 'fix_queue' => $this->build_fix_queue( $sub_scores ), 'calculated_at' => current_time( 'mysql' ), ); /** * Filters the readiness score result. * * @param array $result Score breakdown. * @param \WC_Product $product The product. */ return apply_filters( 'aicommerce_readiness_score', $result, $product ); } /** * Calculate and cache the score, storing it in the readiness cache table. * * @param \WC_Product $product The product. * * @return array The calculated score. */ public function calculate_and_cache( \WC_Product $product ): array { $result = $this->calculate( $product ); $table = $this->wpdb->prefix . 'aicommerce_readiness_cache'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $this->wpdb->replace( $table, array( 'product_id' => $product->get_id(), 'overall_score' => $result['overall'], 'schema_score' => $result['sub_scores']['schema']['score'], 'content_score' => $result['sub_scores']['content']['score'], 'feed_score' => 0, 'crawler_score' => 0, 'agentic_score' => 0, 'details' => wp_json_encode( $result ), 'calculated_at' => $result['calculated_at'], ), array( '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter return $result; } /** * Get cached score for a product. * * @param int $product_id The product ID. * * @return array|null Cached score data or null. */ public function get_cached( int $product_id ): ?array { $table = $this->wpdb->prefix . 'aicommerce_readiness_cache'; // Safe: internal $wpdb->prefix table names; user values use placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT details FROM {$table} WHERE product_id = %d", $product_id ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( ! $row || empty( $row->details ) ) { return null; } $data = json_decode( $row->details, true ); return is_array( $data ) ? $data : null; } /** * Convert a numeric score to a letter grade. * * @param int $score The overall score (0-100). * * @return string Letter grade (A+, A, B, C, D, F). */ public function score_to_grade( int $score ): string { if ( $score >= 95 ) { return 'A+'; } if ( $score >= 80 ) { return 'A'; } if ( $score >= 65 ) { return 'B'; } if ( $score >= 50 ) { return 'C'; } if ( $score >= 35 ) { return 'D'; } return 'F'; } // ------------------------------------------------------------------------- // Private helpers for backward-compatible per-product scoring. // ------------------------------------------------------------------------- /** * Score the schema sub-pillar for a product. * * @param \WC_Product $product The product. * @param ProductSchema $product_schema Product schema generator. * @param Validator $validator Schema validator. * * @return array Sub-score with details. */ private function score_schema( \WC_Product $product, ProductSchema $product_schema, Validator $validator ): array { $checks = array(); $schema = $product_schema->generate( $product ); $validation = $validator->run_local_validation( $schema ); $checks['valid_schema'] = $validation['valid']; $checks['has_offers'] = ! empty( $schema['offers'] ); $checks['has_rating'] = ! empty( $schema['aggregateRating'] ); $checks['has_images'] = ! empty( $schema['image'] ); $checks['no_errors'] = empty( $validation['errors'] ); $checks['no_warnings'] = empty( $validation['warnings'] ); $passed = count( array_filter( $checks ) ); $total = count( $checks ); $score = (int) round( ( $passed / $total ) * 100 ); return array( 'score' => $score, 'checks' => $checks, 'weight' => self::WEIGHTS['schema'], 'label' => __( 'Schema Quality', 'ai-discoverability-kit' ), ); } /** * Score the content sub-pillar for a product. * * @param \WC_Product $product The product. * * @return array Sub-score with details. */ private function score_content( \WC_Product $product ): array { $checks = array(); $description = wp_strip_all_tags( $product->get_description() ); $short_desc = wp_strip_all_tags( $product->get_short_description() ); $name = $product->get_name(); $checks['has_description'] = strlen( $description ) > 50; $checks['has_short_description'] = ! empty( $short_desc ); $checks['detailed_description'] = strlen( $description ) > 200; $checks['good_title'] = strlen( $name ) > 10 && strlen( $name ) < 100; $gallery = $product->get_gallery_image_ids(); $checks['multiple_images'] = count( $gallery ) >= 1 && $product->get_image_id() > 0; $passed = count( array_filter( $checks ) ); $total = count( $checks ); $score = (int) round( ( $passed / $total ) * 100 ); return array( 'score' => $score, 'checks' => $checks, 'weight' => self::WEIGHTS['content'], 'label' => __( 'Content Quality', 'ai-discoverability-kit' ), ); } /** * Score the technical sub-pillar for a product. * * @param \WC_Product $product The product. * * @return array Sub-score with details. */ private function score_technical_product( \WC_Product $product ): array { $checks = array(); $checks['has_price'] = '' !== $product->get_price(); $checks['has_sku'] = ! empty( $product->get_sku() ); $checks['has_stock_status'] = in_array( $product->get_stock_status(), array( 'instock', 'outofstock', 'onbackorder' ), true ); $terms = get_the_terms( $product->get_id(), 'product_cat' ); $checks['has_category'] = is_array( $terms ) && ! empty( $terms ); $checks['has_main_image'] = $product->get_image_id() > 0; $passed = count( array_filter( $checks ) ); $total = count( $checks ); $score = (int) round( ( $passed / $total ) * 100 ); return array( 'score' => $score, 'checks' => $checks, 'weight' => self::WEIGHTS['technical'], 'label' => __( 'Technical Setup', 'ai-discoverability-kit' ), ); } /** * Score the identifiers sub-pillar for a product. * * @param \WC_Product $product The product. * * @return array Sub-score with details. */ private function score_identifiers( \WC_Product $product ): array { $checks = array(); $brand = $product->get_meta( '_aicommerce_brand' ); if ( empty( $brand ) ) { $brand = $product->get_meta( '_wc_brand' ); } if ( empty( $brand ) && taxonomy_exists( 'product_brand' ) ) { $brand_terms = get_the_terms( $product->get_id(), 'product_brand' ); if ( is_array( $brand_terms ) && ! empty( $brand_terms ) ) { $brand = $brand_terms[0]->name; } } if ( empty( $brand ) ) { $brand = get_option( 'aicommerce_default_brand', '' ); } $checks['has_brand'] = ! empty( $brand ); $gtin = $product->get_meta( '_aicommerce_gtin' ); if ( empty( $gtin ) && method_exists( $product, 'get_global_unique_id' ) ) { $gtin = $product->get_global_unique_id(); } if ( empty( $gtin ) ) { $gtin = $product->get_meta( '_global_unique_id' ); } $checks['has_gtin'] = ! empty( $gtin ); $mpn = $product->get_meta( '_aicommerce_mpn' ); if ( empty( $mpn ) ) { $mpn = $product->get_meta( '_mpn' ); } $checks['has_mpn'] = ! empty( $mpn ); $checks['has_reviews'] = $product->get_review_count() > 0; $passed = count( array_filter( $checks ) ); $total = count( $checks ); $score = (int) round( ( $passed / $total ) * 100 ); return array( 'score' => $score, 'checks' => $checks, 'weight' => self::WEIGHTS['identifiers'], 'label' => __( 'Product Identifiers', 'ai-discoverability-kit' ), ); } /** * Build a prioritized fix queue from failed checks. * * @param array> $sub_scores Sub-score data. * * @return array> Ordered list of fixes. */ private function build_fix_queue( array $sub_scores ): array { $fix_labels = array( 'valid_schema' => __( 'Fix schema validation errors', 'ai-discoverability-kit' ), 'has_offers' => __( 'Add pricing information', 'ai-discoverability-kit' ), 'has_rating' => __( 'Get product reviews for rating data', 'ai-discoverability-kit' ), 'has_images' => __( 'Add product images', 'ai-discoverability-kit' ), 'no_errors' => __( 'Resolve schema errors', 'ai-discoverability-kit' ), 'no_warnings' => __( 'Address schema warnings', 'ai-discoverability-kit' ), 'has_description' => __( 'Add a product description (50+ characters)', 'ai-discoverability-kit' ), 'has_short_description' => __( 'Add a short description', 'ai-discoverability-kit' ), 'detailed_description' => __( 'Expand description to 200+ characters', 'ai-discoverability-kit' ), 'good_title' => __( 'Improve product title (10-100 characters)', 'ai-discoverability-kit' ), 'multiple_images' => __( 'Add gallery images', 'ai-discoverability-kit' ), 'has_price' => __( 'Set a product price', 'ai-discoverability-kit' ), 'has_sku' => __( 'Add a SKU', 'ai-discoverability-kit' ), 'has_stock_status' => __( 'Set stock status', 'ai-discoverability-kit' ), 'has_category' => __( 'Assign a product category', 'ai-discoverability-kit' ), 'has_main_image' => __( 'Set a main product image', 'ai-discoverability-kit' ), 'has_brand' => __( 'Add brand information', 'ai-discoverability-kit' ), 'has_gtin' => __( 'Add GTIN/UPC/EAN barcode', 'ai-discoverability-kit' ), 'has_mpn' => __( 'Add manufacturer part number (MPN)', 'ai-discoverability-kit' ), 'has_reviews' => __( 'Encourage customer reviews', 'ai-discoverability-kit' ), ); $priority_order = array( 'schema', 'content', 'technical', 'identifiers' ); $fixes = array(); foreach ( $priority_order as $pillar ) { if ( ! isset( $sub_scores[ $pillar ]['checks'] ) ) { continue; } foreach ( $sub_scores[ $pillar ]['checks'] as $check => $passed ) { if ( ! $passed && isset( $fix_labels[ $check ] ) ) { $fixes[] = array( 'pillar' => $pillar, 'check' => $check, 'action' => $fix_labels[ $check ], ); } } } return $fixes; } }