who uses it. * * @return array */ private static function shopping_bots() { return array( 'OAI-SearchBot' => 'ChatGPT search and shopping', 'ChatGPT-User' => 'ChatGPT agent', 'PerplexityBot' => 'Perplexity search', 'Perplexity-User' => 'Perplexity agent', 'Claude-SearchBot' => 'Claude search', 'Claude-User' => 'Claude agent', 'Googlebot' => 'Google Search and AI Mode', ); } /** * Captcha plugins seen on checkouts. plugin file => [name, whether a signed agent can skip it]. * * @return array */ public static function captcha_plugins() { return array( 'hcaptcha-for-forms-and-more/hcaptcha.php' => array( 'hCaptcha', true ), 'simple-cloudflare-turnstile/simple-cloudflare-turnstile.php' => array( 'Simple Cloudflare Turnstile', true ), 'recaptcha-woo/rcfwc.php' => array( 'reCAPTCHA for WooCommerce', false ), 'advanced-google-recaptcha/advanced-google-recaptcha.php' => array( 'Advanced Google reCAPTCHA', false ), 'advanced-nocaptcha-recaptcha/advanced-nocaptcha-recaptcha.php' => array( 'CAPTCHA 4WP', false ), 'woo-recaptcha/woo-recaptcha.php' => array( 'reCAPTCHA for WooCommerce checkout', false ), ); } /** * @param array $facts * @return array{score:int,checks:array} */ public static function check( array $facts ) { $checks = array(); if ( ! empty( $facts['coming_soon'] ) ) { // Nothing else on this list matters while the shop shows a placeholder. $checks[] = self::item( 'coming_soon', 'fail', 10, 'Store visibility', 'The store is in coming soon mode. Agents and shoppers see a placeholder page, not your products.', 'Launch the store under WooCommerce > Settings > Site visibility.', '' ); } $checks[] = self::item( 'guest_checkout', ! empty( $facts['guest_checkout'] ) ? 'pass' : 'fail', 3, 'Guest checkout', ! empty( $facts['guest_checkout'] ) ? 'Shoppers can check out without an account.' : 'Checkout needs an account. An agent cannot sign up for one on its own, so it stops here.', ! empty( $facts['guest_checkout'] ) ? '' : 'Turn on guest checkout.', ! empty( $facts['guest_checkout'] ) ? '' : 'enable_guest_checkout' ); $captchas = isset( $facts['captcha'] ) && is_array( $facts['captcha'] ) ? $facts['captcha'] : array(); if ( empty( $captchas ) ) { $checks[] = self::item( 'captcha', 'pass', 3, 'Captcha at checkout', 'No captcha plugin found on the checkout.', '', '' ); } else { $names = array(); $supported = true; foreach ( $captchas as $captcha ) { $names[] = $captcha['name']; $supported = $supported && ! empty( $captcha['fast_lane'] ); } $fast = ! empty( $facts['fast_lane'] ); if ( $supported && $fast ) { $checks[] = self::item( 'captcha', 'pass', 3, 'Captcha at checkout', implode( ', ', $names ) . ' is active. Signed agents skip the captcha.', '', '' ); } elseif ( $supported ) { $checks[] = self::item( 'captcha', 'warn', 3, 'Captcha at checkout', implode( ', ', $names ) . ' stops agents at checkout.', 'Let signed agents skip the captcha under Rules. People still see it.', 'enable_fast_lane' ); } else { $checks[] = self::item( 'captcha', 'warn', 3, 'Captcha at checkout', implode( ', ', $names ) . ' stops agents at checkout, and Agent Stamp cannot skip it for a signed agent.', 'Set it to skip verified bots, or use hCaptcha or Simple Cloudflare Turnstile.', '' ); } } $robots = isset( $facts['robots_txt'] ) ? (string) $facts['robots_txt'] : ''; $path = isset( $facts['product_path'] ) && '' !== $facts['product_path'] ? (string) $facts['product_path'] : '/'; $blocked = array(); foreach ( self::shopping_bots() as $token => $use ) { if ( self::robots_blocks( $robots, $token, $path ) ) { $blocked[] = $token . ' (' . $use . ')'; } } $checks[] = self::item( 'robots', empty( $blocked ) ? 'pass' : 'fail', 3, 'robots.txt', empty( $blocked ) ? 'Shopping crawlers and agents may read your product pages.' : 'robots.txt keeps these out of your product pages: ' . implode( ', ', $blocked ) . '.', empty( $blocked ) ? '' : 'Allow them in robots.txt. A shop that search answers cannot read is not recommended.', '' ); $products = isset( $facts['products'] ) && is_array( $facts['products'] ) ? $facts['products'] : array(); $total = isset( $products['total'] ) ? (int) $products['total'] : 0; if ( $total > 0 ) { $weak = array(); foreach ( array( 'price' => 'a price', 'image' => 'an image', 'description' => 'a description' ) as $key => $what ) { $have = isset( $products[ $key ] ) ? (int) $products[ $key ] : 0; if ( $have / $total < 0.9 ) { $weak[] = ( $total - $have ) . ' without ' . $what; } } $checks[] = self::item( 'product_data', empty( $weak ) ? 'pass' : 'fail', 2, 'Product data', empty( $weak ) ? 'At least 9 in 10 products have a price, an image and a description.' : 'Of ' . $total . ' products: ' . implode( ', ', $weak ) . '.', empty( $weak ) ? '' : 'Agents compare products on these fields. Fill them in.', '' ); $gtin = isset( $products['gtin'] ) ? (int) $products['gtin'] : 0; $share = $gtin / $total; $checks[] = self::item( 'gtin', $share >= 0.5 ? 'pass' : 'warn', 1, 'Barcodes (GTIN, UPC, EAN)', round( $share * 100 ) . '% of products have a GTIN, UPC, EAN or ISBN.', $share >= 0.5 ? '' : 'Agents match the same product across stores by barcode. Add it in the product inventory tab where you have one.', '' ); } $checks[] = self::item( 'structured_data', ! empty( $facts['structured_data'] ) ? 'pass' : 'fail', 2, 'Product markup', ! empty( $facts['structured_data'] ) ? 'Product pages carry schema.org Product markup.' : 'WooCommerce product markup is switched off on this site.', ! empty( $facts['structured_data'] ) ? '' : 'Find the theme or plugin that removes WooCommerce structured data and turn it back on.', '' ); $policies = array(); if ( empty( $facts['shipping_zones'] ) ) { $policies[] = 'no shipping zones'; } if ( empty( $facts['refund_page'] ) ) { $policies[] = 'no refund and returns page'; } if ( empty( $facts['terms_page'] ) ) { $policies[] = 'no terms page'; } $checks[] = self::item( 'policies', empty( $policies ) ? 'pass' : 'warn', 1, 'Shipping and returns', empty( $policies ) ? 'Shipping zones, a refund and returns page, and a terms page are set.' : 'WooCommerce has ' . implode( ', ', $policies ) . '.', empty( $policies ) ? '' : 'Agents answer "when does it arrive and can I return it" before they buy. Set these in WooCommerce settings.', '' ); $checks[] = self::item( 'store_api', ! empty( $facts['store_api'] ) ? 'pass' : 'warn', 1, 'Store API', ! empty( $facts['store_api'] ) ? 'The WooCommerce Store API is available for agents that shop by API.' : 'The WooCommerce Store API is not available.', ! empty( $facts['store_api'] ) ? '' : 'Update WooCommerce.', '' ); $checks[] = self::item( 'https', ! empty( $facts['https'] ) && ! empty( $facts['sodium'] ) ? 'pass' : 'fail', 1, 'Signature check', ! empty( $facts['https'] ) && ! empty( $facts['sodium'] ) ? 'HTTPS is on and this server can check Ed25519 agent signatures.' : ( empty( $facts['https'] ) ? 'The site address is not HTTPS. Signed agents sign for an HTTPS address.' : 'PHP sodium is missing, so agent signatures cannot be checked.' ), ! empty( $facts['https'] ) && ! empty( $facts['sodium'] ) ? '' : ( empty( $facts['https'] ) ? 'Ask your host for an HTTPS certificate and set the site address to https://.' : 'Ask your host to enable the PHP sodium extension.' ), '' ); $edge = isset( $facts['edge'] ) ? (string) $facts['edge'] : ''; if ( '' !== $edge ) { $where = self::edge_settings( $edge ); $checks[] = self::item( 'edge', 'warn', 2, 'Firewall in front of the store', $edge . ' answers before WordPress does. If it challenges or blocks an AI agent there, Agent Stamp never sees the visit, so the Agents tab cannot count it.', 'In ' . $edge . ', ' . $where, '' ); } $refused = isset( $facts['blocked_sessions'] ) ? (int) $facts['blocked_sessions'] : 0; $days = isset( $facts['blocked_days'] ) ? (int) $facts['blocked_days'] : 30; $checks[] = self::item( 'refused', 0 === $refused ? 'pass' : 'fail', 3, 'Agents refused', 0 === $refused ? 'No agent was refused or rate limited in the last ' . $days . ' days.' : ( 1 === $refused ? '1 agent visit in the last ' . $days . ' days was refused or rate limited.' : $refused . ' agent visits in the last ' . $days . ' days were refused or rate limited.' ), 0 === $refused ? '' : 'Open the Agents tab for the reason, and allow signed agents in the plugin that refused them.', '' ); $weights = 0; $earned = 0.0; foreach ( $checks as $check ) { $weights += $check['weight']; $earned += $check['weight'] * ( 'pass' === $check['status'] ? 1.0 : ( 'warn' === $check['status'] ? 0.5 : 0.0 ) ); } return array( 'score' => $weights > 0 ? (int) round( 100 * $earned / $weights ) : 0, 'checks' => $checks, ); } /** * Which edge network answered this request, from the headers it adds. Empty when none. * * @param array $headers Lowercase header names. * @return string */ /** * The page cache on this store, named when its plugin is active. * * @param bool $wp_cache WP_CACHE is on. * @param bool $dropin wp-content/advanced-cache.php exists. * @param string[] $active_plugins * @return string Empty when no page cache is seen. */ public static function page_cache( $wp_cache, $dropin, array $active_plugins ) { $known = array( 'wp-rocket' => 'WP Rocket', 'litespeed-cache' => 'LiteSpeed Cache', 'w3-total-cache' => 'W3 Total Cache', 'wp-super-cache' => 'WP Super Cache', 'wp-fastest-cache' => 'WP Fastest Cache', 'sg-cachepress' => 'SiteGround Speed Optimizer', 'breeze' => 'Breeze', 'cache-enabler' => 'Cache Enabler', 'hummingbird-performance' => 'Hummingbird', 'wp-optimize' => 'WP-Optimize', 'nitropack' => 'NitroPack', ); foreach ( $active_plugins as $file ) { $slug = strtok( (string) $file, '/' ); if ( isset( $known[ $slug ] ) ) { return $known[ $slug ]; } } return ( $wp_cache || $dropin ) ? 'A page cache' : ''; } public static function edge_from_headers( array $headers ) { if ( isset( $headers['cf-ray'] ) || isset( $headers['cf-connecting-ip'] ) ) { return 'Cloudflare'; } if ( isset( $headers['x-sucuri-id'] ) || isset( $headers['x-sucuri-clientip'] ) ) { return 'Sucuri'; } if ( isset( $headers['x-amz-cf-id'] ) ) { return 'Amazon CloudFront'; } if ( isset( $headers['fastly-client-ip'] ) || isset( $headers['x-fastly-request-id'] ) ) { return 'Fastly'; } if ( isset( $headers['akamai-origin-hop'] ) || isset( $headers['true-client-ip'] ) ) { return 'Akamai'; } return ''; } /** * Where to look in each edge network's settings. * * @param string $edge * @return string */ public static function edge_settings( $edge ) { $where = array( 'Cloudflare' => 'open Security, then Bots (and AI Crawl Control where your plan has it). Allow verified bots and signed agents, and do not challenge them on product, cart and checkout pages.', 'Sucuri' => 'open the firewall settings and allow verified AI agents, or allow-list their published addresses, for product, cart and checkout pages.', 'Amazon CloudFront' => 'check the AWS WAF rules on the distribution: the Bot Control rule group can block AI agents. Allow verified bots and signed agents.', 'Fastly' => 'check the Next-Gen WAF bot rules and allow verified bots and signed agents.', 'Akamai' => 'check Bot Manager and allow verified bots and signed agents.', ); return isset( $where[ $edge ] ) ? $where[ $edge ] : 'check its bot settings and allow verified bots and signed agents.'; } /** * Does robots.txt keep this crawler out of this path? Longest match wins, Allow wins a tie. * * @param string $robots * @param string $token * @param string $path * @return bool */ public static function robots_blocks( $robots, $token, $path ) { $groups = array(); $current = null; $in_ua = false; foreach ( preg_split( '/\r\n|\r|\n/', (string) $robots ) as $line ) { $line = trim( preg_replace( '/#.*$/', '', $line ) ); if ( '' === $line || false === strpos( $line, ':' ) ) { continue; } list( $field, $value ) = array_map( 'trim', explode( ':', $line, 2 ) ); $field = strtolower( $field ); if ( 'user-agent' === $field ) { if ( ! $in_ua ) { $groups[] = array( 'agents' => array(), 'rules' => array(), ); $current = count( $groups ) - 1; } $groups[ $current ]['agents'][] = strtolower( $value ); $in_ua = true; continue; } $in_ua = false; if ( null !== $current && in_array( $field, array( 'allow', 'disallow' ), true ) ) { $groups[ $current ]['rules'][] = array( $field, $value ); } } $rules = null; $star = null; foreach ( $groups as $group ) { if ( in_array( strtolower( $token ), $group['agents'], true ) ) { $rules = array_merge( (array) $rules, $group['rules'] ); } elseif ( in_array( '*', $group['agents'], true ) ) { $star = array_merge( (array) $star, $group['rules'] ); } } if ( null === $rules ) { $rules = (array) $star; } $best = -1; $disallowed = false; foreach ( $rules as $rule ) { list( $field, $pattern ) = $rule; if ( '' === $pattern ) { continue; } if ( ! self::robots_match( $pattern, $path ) ) { continue; } $length = strlen( $pattern ); if ( $length > $best || ( $length === $best && 'allow' === $field ) ) { $best = $length; $disallowed = 'disallow' === $field; } } return $disallowed; } /** * @param string $pattern * @param string $path * @return bool */ private static function robots_match( $pattern, $path ) { $anchored = '$' === substr( $pattern, -1 ); if ( $anchored ) { $pattern = substr( $pattern, 0, -1 ); } $regex = '#^' . str_replace( '\*', '.*', preg_quote( $pattern, '#' ) ) . ( $anchored ? '$' : '' ) . '#'; return 1 === preg_match( $regex, $path ); } /** * @return array{id:string,status:string,title:string,detail:string,fix:string,action:string,weight:int} */ private static function item( $id, $status, $weight, $title, $detail, $fix, $action ) { return array( 'id' => $id, 'status' => $status, 'weight' => $weight, 'title' => $title, 'detail' => $detail, 'fix' => $fix, 'action' => $action, ); } /** * What the store looks like right now. * * @param int $blocked_sessions Agent visits refused in the last $blocked_days days. * @param int $blocked_days Days the refused count covers. * @return array */ public static function facts( $blocked_sessions, $blocked_days = 30 ) { $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } $captchas = array(); foreach ( self::captcha_plugins() as $file => $plugin ) { if ( in_array( $file, $active, true ) ) { $captchas[] = array( 'name' => $plugin[0], 'fast_lane' => $plugin[1], ); } } $rules = AgentStamp_Policy::rules(); $product_path = '/'; $sample = function_exists( 'wc_get_products' ) ? wc_get_products( array( 'limit' => 1, 'status' => 'publish', 'return' => 'ids' ) ) : array(); if ( ! empty( $sample ) ) { $link = wp_parse_url( get_permalink( (int) $sample[0] ), PHP_URL_PATH ); $product_path = is_string( $link ) && '' !== $link ? $link : '/'; } $structured = false; if ( function_exists( 'WC' ) && isset( WC()->structured_data ) && is_object( WC()->structured_data ) ) { $structured = false !== has_action( 'wp_footer', array( WC()->structured_data, 'output_structured_data' ) ); } $zones = 0; if ( class_exists( 'WC_Shipping_Zones' ) ) { $zones = count( WC_Shipping_Zones::get_zones() ); } return array( 'coming_soon' => 'yes' === get_option( 'woocommerce_coming_soon', 'no' ), 'guest_checkout' => 'yes' === get_option( 'woocommerce_enable_guest_checkout', 'yes' ), 'captcha' => $captchas, 'fast_lane' => ! empty( $rules['fast_lane'] ), 'robots_txt' => self::robots_txt(), 'product_path' => $product_path, 'products' => self::product_counts(), 'structured_data' => $structured, 'shipping_zones' => $zones, 'refund_page' => (int) get_option( 'woocommerce_refund_returns_page_id', 0 ) > 0, 'terms_page' => (int) get_option( 'woocommerce_terms_page_id', 0 ) > 0, 'store_api' => class_exists( '\Automattic\WooCommerce\StoreApi\StoreApi' ), 'https' => 0 === strpos( (string) home_url(), 'https://' ), 'sodium' => function_exists( 'sodium_crypto_sign_verify_detached' ), 'blocked_sessions' => (int) $blocked_sessions, 'blocked_days' => max( 1, (int) $blocked_days ), 'edge' => self::edge_from_headers( AgentStamp_Sensor::request()->headers ), ); } /** * The robots.txt a crawler gets: the file when there is one, else what WordPress writes. * * @return string */ private static function robots_txt() { $file = ABSPATH . 'robots.txt'; if ( is_readable( $file ) ) { return (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file. } $public = (string) get_option( 'blog_public', '1' ); if ( '0' === $public ) { return "User-agent: *\nDisallow: /\n"; } return (string) apply_filters( 'robots_txt', "User-agent: *\nDisallow: /wp-admin/\nAllow: /wp-admin/admin-ajax.php\n", $public ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core filter. } /** * Published products and how many carry each field. Cached for an hour. * * @return array */ private static function product_counts() { $cached = get_transient( 'agent_stamp_product_counts' ); if ( is_array( $cached ) ) { return $cached; } global $wpdb; $posts = $wpdb->posts; $meta = $wpdb->postmeta; $counts = array( 'total' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$posts} WHERE post_type = 'product' AND post_status = 'publish'" ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared 'description' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$posts} WHERE post_type = 'product' AND post_status = 'publish' AND ( post_content <> '' OR post_excerpt <> '' )" ), // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared ); foreach ( array( 'price' => '_price', 'image' => '_thumbnail_id', 'gtin' => '_global_unique_id' ) as $key => $meta_key ) { $counts[ $key ] = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) FROM {$posts} p INNER JOIN {$meta} m ON m.post_id = p.ID WHERE p.post_type = 'product' AND p.post_status = 'publish' AND m.meta_key = %s AND m.meta_value <> '' AND m.meta_value <> '0'", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $meta_key ) ); } set_transient( 'agent_stamp_product_counts', $counts, HOUR_IN_SECONDS ); return $counts; } }