0; } /** * Read action fire count safely in environments where did_action is unavailable. * * @param string $hook Hook name. * @return int */ private static function get_action_count( $hook ) { if ( ! function_exists( 'did_action' ) ) { return 0; } return intval( did_action( $hook ) ); } /** * Safely read Carbon Fields option with fallback while fields are still booting. * * @param string $key Option key. * @param mixed $default Fallback value. * @return mixed */ private static function get_theme_option_safe( $key, $default = null ) { if ( ! self::is_carbon_ready() ) { return $default; } $value = carbon_get_theme_option( $key ); return ( null === $value || '' === $value ) ? $default : $value; } /** * Score breakdown categories and max points. */ const SCORE_WEIGHTS = array( 'https' => 15, 'jsonld' => 20, 'org_schema' => 15, 'meta_desc' => 10, 'sitemap' => 10, 'robots' => 5, 'speed' => 15, 'title' => 10, ); /** * Handshake limits based on score ranges. */ const HANDSHAKE_LIMITS = array( 40 => 5, // score < 40 -> max 5 partners 60 => 10, // score 40-59 -> max 10 80 => 15, // score 60-79 -> max 15 101 => 20, // score 80-100 -> max 20 ); /** * Cache TTL for remote score results. */ const SCORE_CACHE_TTL = 21600; // 6 hours. /** * Option key for score cache key index. */ const SCORE_CACHE_KEYS_OPTION = 'aish_score_cache_keys'; /** * Score a remote URL. * * @param string $url The URL to evaluate. * @param bool $force_refresh Whether to bypass cache. * @return array Score details with total, breakdown, and max. */ public static function score_url( $url, $force_refresh = false ) { $breakdown = array(); $total = 0; // Normalize URL. $url = trailingslashit( esc_url_raw( $url ) ); if ( ! AISH_REST::is_safe_public_url( $url ) ) { return self::build_result( 0, $breakdown, __( 'URL blocked by outbound safety policy', 'ai-semantic-handshake' ) ); } $cache_key = self::build_score_cache_key( $url ); if ( ! $force_refresh ) { $cached = get_transient( $cache_key ); if ( false !== $cached && is_array( $cached ) ) { return $cached; } } // --- 1. HTTPS Check (15 pts) --- $is_https = strpos( $url, 'https://' ) === 0; $breakdown['https'] = array( 'label' => __( 'HTTPS', 'ai-semantic-handshake' ), 'points' => $is_https ? self::SCORE_WEIGHTS['https'] : 0, 'max' => self::SCORE_WEIGHTS['https'], 'pass' => $is_https, ); $total += $breakdown['https']['points']; // Fetch the page. $response = self::remote_get( $url, 10 ); if ( is_wp_error( $response ) ) { $result = self::build_result( $total, $breakdown, __( 'Could not reach site', 'ai-semantic-handshake' ) ); self::cache_score_result( $cache_key, $result ); return $result; } $body = wp_remote_retrieve_body( $response ); $http_code = wp_remote_retrieve_response_code( $response ); if ( $http_code !== 200 || empty( $body ) ) { $result = self::build_result( $total, $breakdown, sprintf( /* translators: %d: HTTP status code */ __( 'HTTP %d', 'ai-semantic-handshake' ), $http_code ) ); self::cache_score_result( $cache_key, $result ); return $result; } // --- 2. JSON-LD Check (20 pts) --- $has_jsonld = preg_match( '/ __( 'JSON-LD Schema', 'ai-semantic-handshake' ), 'points' => $has_jsonld ? self::SCORE_WEIGHTS['jsonld'] : 0, 'max' => self::SCORE_WEIGHTS['jsonld'], 'pass' => (bool) $has_jsonld, ); $total += $breakdown['jsonld']['points']; // --- 3. Organization/Person Schema (15 pts) --- $has_org = preg_match( '/"@type"\s*:\s*"(Organization|Person)"/', $body ); $breakdown['org_schema'] = array( 'label' => __( 'Organization/Person Schema', 'ai-semantic-handshake' ), 'points' => $has_org ? self::SCORE_WEIGHTS['org_schema'] : 0, 'max' => self::SCORE_WEIGHTS['org_schema'], 'pass' => (bool) $has_org, ); $total += $breakdown['org_schema']['points']; // --- 4. Meta Description (10 pts) --- $has_meta_desc = preg_match( '/ __( 'Meta Description', 'ai-semantic-handshake' ), 'points' => $has_meta_desc ? self::SCORE_WEIGHTS['meta_desc'] : 0, 'max' => self::SCORE_WEIGHTS['meta_desc'], 'pass' => (bool) $has_meta_desc, ); $total += $breakdown['meta_desc']['points']; // --- 5. Title Tag (10 pts) --- $has_title = preg_match( '/.+<\/title>/i', $body ); $breakdown['title'] = array( 'label' => __( 'Title Tag', 'ai-semantic-handshake' ), 'points' => $has_title ? self::SCORE_WEIGHTS['title'] : 0, 'max' => self::SCORE_WEIGHTS['title'], 'pass' => (bool) $has_title, ); $total += $breakdown['title']['points']; // --- 6. Page Load Speed (15 pts) --- $start_time = microtime( true ); $head_response = self::remote_head( $url, 5 ); $load_time = microtime( true ) - $start_time; $speed_pass = ! is_wp_error( $head_response ) && $load_time < 3.0; $speed_points = 0; if ( ! is_wp_error( $head_response ) ) { if ( $load_time < 1.0 ) { $speed_points = self::SCORE_WEIGHTS['speed']; } elseif ( $load_time < 2.0 ) { $speed_points = intval( self::SCORE_WEIGHTS['speed'] * 0.7 ); } elseif ( $load_time < 3.0 ) { $speed_points = intval( self::SCORE_WEIGHTS['speed'] * 0.4 ); } } $breakdown['speed'] = array( /* translators: %s: response time in seconds with one decimal place */ 'label' => sprintf( __( 'Response Time (%.1fs)', 'ai-semantic-handshake' ), $load_time ), 'points' => $speed_points, 'max' => self::SCORE_WEIGHTS['speed'], 'pass' => $speed_pass, ); $total += $speed_points; // --- 7. Sitemap Check (10 pts) --- $sitemap_url = trailingslashit( $url ) . 'sitemap.xml'; $sitemap_response = self::remote_head( $sitemap_url, 5 ); $has_sitemap = ! is_wp_error( $sitemap_response ) && wp_remote_retrieve_response_code( $sitemap_response ) === 200; $breakdown['sitemap'] = array( 'label' => __( 'Sitemap.xml', 'ai-semantic-handshake' ), 'points' => $has_sitemap ? self::SCORE_WEIGHTS['sitemap'] : 0, 'max' => self::SCORE_WEIGHTS['sitemap'], 'pass' => $has_sitemap, ); $total += $breakdown['sitemap']['points']; // --- 8. Robots.txt Check (5 pts) --- $robots_url = trailingslashit( $url ) . 'robots.txt'; $robots_response = self::remote_head( $robots_url, 5 ); $has_robots = ! is_wp_error( $robots_response ) && wp_remote_retrieve_response_code( $robots_response ) === 200; $breakdown['robots'] = array( 'label' => __( 'Robots.txt', 'ai-semantic-handshake' ), 'points' => $has_robots ? self::SCORE_WEIGHTS['robots'] : 0, 'max' => self::SCORE_WEIGHTS['robots'], 'pass' => $has_robots, ); $total += $breakdown['robots']['points']; $result = self::build_result( $total, $breakdown ); self::cache_score_result( $cache_key, $result ); return $result; } /** * Score the current site (self-assessment). * * @param bool $force_refresh Whether to bypass cache. * @return array */ public static function score_self( $force_refresh = false ) { return self::score_url( get_site_url(), $force_refresh ); } /** * Clear score cache for one URL or all tracked URLs. * * @param string $url Optional URL to clear. */ public static function clear_score_cache( $url = '' ) { $keys = get_option( self::SCORE_CACHE_KEYS_OPTION, array() ); if ( ! is_array( $keys ) ) { $keys = array(); } if ( ! empty( $url ) ) { $key = self::build_score_cache_key( trailingslashit( esc_url_raw( $url ) ) ); delete_transient( $key ); unset( $keys[ $key ] ); update_option( self::SCORE_CACHE_KEYS_OPTION, $keys, false ); return; } foreach ( array_keys( $keys ) as $key ) { delete_transient( $key ); } delete_option( self::SCORE_CACHE_KEYS_OPTION ); } /** * Get the handshake limit for a given score. * * @param int $score The quality score. * @return int Maximum number of handshakes. */ public static function get_handshake_limit( $score ) { foreach ( self::HANDSHAKE_LIMITS as $threshold => $limit ) { if ( $score < $threshold ) { return $limit; } } return 20; } /** * Get advisory handshake capacity data for the current site. * * @return array [ 'allowed' => bool, 'current' => int, 'limit' => int, 'score' => int, 'recommended_exceeded' => bool ] */ public static function can_add_partner() { // Use cached self-score if available (refreshed daily). $cached = get_transient( 'aish_self_score' ); if ( false === $cached ) { // Avoid remote scoring while Carbon Fields is still registering settings UI. if ( function_exists( 'did_action' ) && self::get_action_count( 'carbon_fields_register_fields' ) > 0 && 0 === self::get_action_count( 'carbon_fields_fields_registered' ) ) { $cached = array( 'total' => 100, 'grade' => 'A', ); } else { $cached = self::score_self(); set_transient( 'aish_self_score', $cached, DAY_IN_SECONDS ); } } if ( ! is_array( $cached ) || ! isset( $cached['total'] ) ) { $cached = array( 'total' => 100, 'grade' => 'A', ); } $score = $cached['total']; $limit = self::get_handshake_limit( $score ); $partners = class_exists( 'AISH_Partner_Store' ) ? AISH_Partner_Store::get_all() : self::get_theme_option_safe( 'aish_partners', array() ); $current = self::count_reserved_partner_slots( $partners ); $recommended_exceeded = $current >= $limit; return array( 'allowed' => true, 'current' => $current, 'limit' => $limit, 'score' => $score, 'recommended_exceeded' => $recommended_exceeded, ); } /** * Build a standardized score result. * * @param int $total Total score. * @param array $breakdown Score breakdown. * @param string $error Optional error message. * @return array */ private static function build_result( $total, $breakdown, $error = '' ) { return array( 'total' => $total, 'max' => 100, 'breakdown' => $breakdown, 'error' => $error, 'grade' => self::get_grade( $total ), ); } /** * Build score cache key for URL. * * @param string $url URL. * @return string */ private static function build_score_cache_key( $url ) { return 'aish_score_' . md5( strtolower( trim( $url ) ) ); } /** * Store score result and track cache key. * * @param string $cache_key Cache key. * @param array $result Score result. */ private static function cache_score_result( $cache_key, $result ) { set_transient( $cache_key, $result, self::SCORE_CACHE_TTL ); $keys = get_option( self::SCORE_CACHE_KEYS_OPTION, array() ); if ( ! is_array( $keys ) ) { $keys = array(); } $keys[ $cache_key ] = time(); // Keep index bounded. if ( count( $keys ) > 500 ) { asort( $keys ); $keys = array_slice( $keys, -500, null, true ); } update_option( self::SCORE_CACHE_KEYS_OPTION, $keys, false ); } /** * Build default HTTP request args for scorer network calls. * * @param int $timeout Request timeout. * @return array */ private static function request_args( $timeout ) { $ssl_verify = self::get_ssl_verify_setting(); if ( class_exists( 'AISH_REST' ) ) { return AISH_REST::build_remote_request_args( $timeout, $ssl_verify ); } return array( 'timeout' => $timeout, 'sslverify' => $ssl_verify, 'redirection' => 0, 'reject_unsafe_urls' => true, 'headers' => array( 'User-Agent' => 'AI-Semantic-Handshake/' . AISH_VERSION . ' (WordPress; ' . get_site_url() . ')', ), ); } /** * Wrapper for wp_remote_get with unified request args. * * @param string $url URL. * @param int $timeout Timeout. * @return array|WP_Error */ private static function remote_get( $url, $timeout ) { return wp_remote_get( $url, self::request_args( $timeout ) ); } /** * Wrapper for wp_remote_head with unified request args. * * @param string $url URL. * @param int $timeout Timeout. * @return array|WP_Error */ private static function remote_head( $url, $timeout ) { return wp_remote_head( $url, self::request_args( $timeout ) ); } /** * Resolve SSL verification setting with secure default. * * @return bool */ private static function get_ssl_verify_setting() { $value = self::get_theme_option_safe( 'aish_ssl_verify', true ); if ( '' === $value || null === $value ) { return true; } return (bool) $value; } /** * Get a letter grade for a score. * * @param int $score The score. * @return string */ private static function get_grade( $score ) { if ( $score >= 80 ) return 'A'; if ( $score >= 60 ) return 'B'; if ( $score >= 40 ) return 'C'; if ( $score >= 20 ) return 'D'; return 'F'; } /** * Render a score as an HTML badge. * * @param int $score The score. * @param string $grade The grade letter. * @return string HTML. */ public static function render_score_badge( $score, $grade = '' ) { if ( empty( $grade ) ) { $grade = self::get_grade( $score ); } $colors = array( 'A' => '#00a32a', 'B' => '#2271b1', 'C' => '#dba617', 'D' => '#d63638', 'F' => '#8c0000', ); $color = isset( $colors[ $grade ] ) ? $colors[ $grade ] : '#666'; return sprintf( ' %s %d/100 ', $color, esc_html( $grade ), $score ); } /** * Render a detailed score breakdown as HTML. * * @param array $result The score result from score_url(). * @return string HTML. */ public static function render_breakdown( $result ) { $html = '
'; foreach ( $result['breakdown'] as $item ) { $icon = $item['pass'] ? __( 'OK', 'ai-semantic-handshake' ) : __( 'X', 'ai-semantic-handshake' ); $html .= sprintf( '%s %s (%d/%d)', $item['pass'] ? '#e6f4ea' : '#fce8e6', $icon, esc_html( $item['label'] ), $item['points'], $item['max'] ); } $html .= '
'; return $html; } }