array( 'Cache-Control' => 'no-cache, no-store, max-age=0', 'Pragma' => 'no-cache', ), ) ) ); if ( is_wp_error( $response ) ) { $result = self::result( 'broken', $response->get_error_message(), self::ERR_FETCH_FAILED ); self::cache_verify_result( $cache_key, $result ); return $result; } $http_code = wp_remote_retrieve_response_code( $response ); if ( $http_code !== 200 ) { $result = self::result( 'broken', sprintf( /* translators: %d: HTTP status code */ __( 'HTTP %d', 'ai-semantic-handshake' ), $http_code ), self::ERR_HTTP_ERROR ); self::cache_verify_result( $cache_key, $result ); return $result; } $body = wp_remote_retrieve_body( $response ); if ( empty( $body ) ) { $result = self::result( 'broken', __( 'Empty response body', 'ai-semantic-handshake' ), self::ERR_EMPTY_BODY ); self::cache_verify_result( $cache_key, $result ); return $result; } // Parse JSON-LD blocks from the HTML. $jsonld_blocks = self::extract_jsonld( $body ); if ( empty( $jsonld_blocks ) ) { $result = self::result( 'broken', __( 'No JSON-LD found on page', 'ai-semantic-handshake' ), self::ERR_NO_JSONLD ); self::cache_verify_result( $cache_key, $result ); return $result; } // Check if any JSON-LD block references our site. $my_url = get_site_url(); $my_domain = AISH_REST::normalize_url( $my_url ); foreach ( $jsonld_blocks as $data ) { if ( self::check_node_for_reference( $data, $my_domain ) ) { $result = self::result( 'active', __( 'Bidirectional handshake confirmed', 'ai-semantic-handshake' ), self::ERR_OK ); self::cache_verify_result( $cache_key, $result ); return $result; } } $result = self::result( 'broken', __( 'Our site not referenced in partner JSON-LD', 'ai-semantic-handshake' ), self::ERR_NO_BACKREF ); self::cache_verify_result( $cache_key, $result ); return $result; } /** * Clear verification cache for one URL or all tracked URLs. * * @param string $url Optional URL to clear. */ public static function clear_cache( $url = '' ) { $keys = get_option( self::VERIFY_CACHE_KEYS_OPTION, array() ); if ( ! is_array( $keys ) ) { $keys = array(); } if ( ! empty( $url ) ) { $key = self::build_verify_cache_key( $url ); delete_transient( $key ); unset( $keys[ $key ] ); update_option( self::VERIFY_CACHE_KEYS_OPTION, $keys, false ); return; } foreach ( array_keys( $keys ) as $key ) { delete_transient( $key ); } delete_option( self::VERIFY_CACHE_KEYS_OPTION ); } /** * Extract JSON-LD blocks from HTML. * * @param string $html The HTML content. * @return array Array of decoded JSON-LD objects. */ private static function extract_jsonld( $html ) { $blocks = array(); if ( preg_match_all( '/]*type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/si', $html, $matches ) ) { foreach ( $matches[1] as $json_str ) { $data = json_decode( trim( $json_str ), true ); if ( ! is_array( $data ) ) { continue; } // Some generators emit a top-level JSON array of nodes. if ( self::is_list_array( $data ) ) { foreach ( $data as $entry ) { if ( is_array( $entry ) ) { $blocks[] = $entry; } } } else { $blocks[] = $data; } } } return $blocks; } /** * Recursively check a JSON-LD node (and its graph) for a reference to our domain. * * @param array $data The JSON-LD data (could be a node, graph, or nested structure). * @param string $my_domain Our normalized domain. * @return bool */ private static function check_node_for_reference( $data, $my_domain ) { if ( ! is_array( $data ) ) { return false; } // Some documents are a plain list of nodes. if ( self::is_list_array( $data ) ) { foreach ( $data as $node ) { if ( self::check_node_for_reference( $node, $my_domain ) ) { return true; } } return false; } // Handle @graph arrays (Yoast, Rank Math). if ( isset( $data['@graph'] ) && is_array( $data['@graph'] ) ) { foreach ( $data['@graph'] as $node ) { if ( self::check_single_node( $node, $my_domain ) ) { return true; } } return false; } // Single node. return self::check_single_node( $data, $my_domain ); } /** * Check a single JSON-LD node for Organization/Person that references our domain. * * @param array $node A JSON-LD node. * @param string $my_domain Our normalized domain. * @return bool */ private static function check_single_node( $node, $my_domain ) { if ( ! is_array( $node ) || ! isset( $node['@type'] ) ) { return false; } // Check if this is an Organization or Person node. $type = $node['@type']; $is_entity = false; if ( is_array( $type ) ) { $is_entity = in_array( 'Organization', $type, true ) || in_array( 'Person', $type, true ); } else { $is_entity = ( 'Organization' === $type || 'Person' === $type ); } if ( ! $is_entity ) { return false; } // Check relation fields for references to our domain. $relation_fields = array( 'knowsAbout', 'mentions', 'sameAs' ); foreach ( $relation_fields as $field ) { if ( ! isset( $node[ $field ] ) ) { continue; } foreach ( self::normalize_relation_values( $node[ $field ] ) as $related_url ) { $related_domain = AISH_REST::normalize_url( $related_url ); if ( $related_domain === $my_domain ) { return true; } } } return false; } /** * Normalize relation values into a list of URLs. * * @param mixed $value Schema relation value. * @return array */ private static function normalize_relation_values( $value ) { $urls = array(); if ( is_string( $value ) ) { if ( filter_var( $value, FILTER_VALIDATE_URL ) ) { $urls[] = $value; } return $urls; } if ( is_array( $value ) ) { if ( self::is_list_array( $value ) ) { foreach ( $value as $entry ) { $urls = array_merge( $urls, self::normalize_relation_values( $entry ) ); } return $urls; } foreach ( array( 'url', '@id', 'sameAs' ) as $key ) { if ( isset( $value[ $key ] ) ) { $urls = array_merge( $urls, self::normalize_relation_values( $value[ $key ] ) ); } } } return $urls; } /** * Check whether an array is a list-like array. * * @param array $value Array to inspect. * @return bool */ private static function is_list_array( $value ) { if ( ! is_array( $value ) ) { return false; } return array_keys( $value ) === range( 0, count( $value ) - 1 ); } /** * Build verify cache key. * * @param string $url URL. * @return string */ private static function build_verify_cache_key( $url ) { return 'aish_verify_' . md5( strtolower( trim( esc_url_raw( $url ) ) ) ); } /** * Add a lightweight cache-buster query parameter. * * @param string $url URL. * @return string */ private static function add_cache_buster( $url ) { $url = (string) $url; $sep = false === strpos( $url, '?' ) ? '?' : '&'; return $url . $sep . 'aish_nc=' . time(); } /** * Store verification result and track cache key. * * @param string $cache_key Cache key. * @param array $result Verification result. */ private static function cache_verify_result( $cache_key, $result ) { set_transient( $cache_key, $result, self::VERIFY_CACHE_TTL ); $keys = get_option( self::VERIFY_CACHE_KEYS_OPTION, array() ); if ( ! is_array( $keys ) ) { $keys = array(); } $keys[ $cache_key ] = time(); if ( count( $keys ) > 500 ) { asort( $keys ); $keys = array_slice( $keys, -500, null, true ); } update_option( self::VERIFY_CACHE_KEYS_OPTION, $keys, false ); } /** * Build a standardized result array. * * @param string $status 'active' or 'broken'. * @param string $error_detail Detail message. * @param string $error_code Machine-readable verification code. * @return array */ private static function result( $status, $error_detail = '', $error_code = '' ) { return array( 'status' => $status, 'timestamp' => current_time( 'mysql' ), 'error_code' => $error_code, 'error_detail' => $error_detail, ); } }