40, 'plain_links' => 10, 'no_https' => 10, 'no_meta' => 10, 'no_description' => 15, 'no_image' => 10, 'no_alt' => 8, 'thin_description' => 5, 'duplicate_title' => 5, 'uncategorized' => 5, 'long_title' => 2, ); private const SITE = array( 'hidden', 'plain_links', 'no_https', 'no_meta' ); /** What each check found, and what helps. */ private const TEXTS = array( 'hidden' => array('Search engines are asked not to index this site', 'Settings → Reading: untick "Discourage search engines from indexing this site" when the store is ready.'), 'plain_links' => array('Addresses use plain links (?p=123)', 'Settings → Permalinks: choose "Post name" for readable product addresses.'), 'no_https' => array('The site address does not use HTTPS', 'Search engines prefer secure addresses. Ask your host for an SSL certificate, then change the site address to https.'), 'no_meta' => array('Product pages have no meta description', 'Without an SEO tool, search engines write their own snippet from the page.'), 'no_description' => array('Products without a description', 'A description tells shoppers and search engines what the product is.'), 'no_image' => array('Products without a photo', 'Product photos appear in image search and next to search results.'), 'no_alt' => array('Product photos without alt text', 'Alt text describes the photo for image search and for screen readers.'), 'thin_description' => array('Products with a very short description', 'A few more sentences about material, size and use help the product to be found.'), 'duplicate_title' => array('Products with the same name as another product', 'Search engines may treat pages with the same title as duplicates.'), 'uncategorized' => array('Products only in "Uncategorized"', 'Categories give search engines and shoppers a path to the product.'), 'long_title' => array('Product names longer than 70 characters', 'Search results cut long titles short.'), ); /** SEO plugins that write meta descriptions: a constant each defines, and its plugin folder. */ private const SEO_PLUGINS = array( 'WPSEO_VERSION' => 'wordpress-seo/', 'RANK_MATH_VERSION' => 'seo-by-rank-math/', 'AIOSEO_VERSION' => 'all-in-one-seo-pack/', 'SEOPRESS_VERSION' => 'wp-seopress/', 'THE_SEO_FRAMEWORK_VERSION' => 'autodescription/', 'SLIM_SEO_VERSION' => 'slim-seo/', ); /** seo_health_read: the summary, from the six-hour copy unless refresh is asked for. */ public static function read( array $data = array() ) : array { Store::require_cap( 'manage_woocommerce' ); $found = ( empty( $data['refresh'] ) ? get_transient( self::CACHE ) : false ); if ( !is_array( $found ) || ($found['version'] ?? 0) !== 1 ) { $found = self::measure(); set_transient( self::CACHE, $found, self::TTL ); } return self::summary( $found ); } /** The counts behind the summary: no text, so the summary follows the reader's language. */ public static function measure() : array { global $wpdb; $checks = array(); $checks['hidden'] = array( 'n' => ( (string) get_option( 'blog_public', '1' ) === '0' ? 1 : 0 ), ); $checks['plain_links'] = array( 'n' => ( (string) get_option( 'permalink_structure', '' ) === '' ? 1 : 0 ), ); $checks['no_https'] = array( 'n' => ( wp_parse_url( home_url(), PHP_URL_SCHEME ) === 'https' ? 0 : 1 ), ); $checks['no_meta'] = array( 'n' => ( self::writes_meta() ? 0 : 1 ), ); $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE post_type='product' AND post_status='publish'", $wpdb->posts ) ); if ( $total > 0 ) { $checks['no_description'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, SUBSTRING_INDEX(GROUP_CONCAT(ID ORDER BY post_date DESC), ',', 3) AS ids FROM %i WHERE post_type='product' AND post_status='publish' AND TRIM(post_content)=''", $wpdb->posts ), ARRAY_A ) ); $checks['thin_description'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, SUBSTRING_INDEX(GROUP_CONCAT(ID ORDER BY post_date DESC), ',', 3) AS ids FROM %i WHERE post_type='product' AND post_status='publish' AND TRIM(post_content)<>'' AND CHAR_LENGTH(post_content)<%d", $wpdb->posts, 300 ), ARRAY_A ) ); $checks['no_image'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, SUBSTRING_INDEX(GROUP_CONCAT(p.ID ORDER BY p.post_date DESC), ',', 3) AS ids FROM %i p WHERE p.post_type='product' AND p.post_status='publish' AND NOT EXISTS (SELECT 1 FROM %i m WHERE m.post_id=p.ID AND m.meta_key='_thumbnail_id' AND m.meta_value NOT IN ('','0'))", $wpdb->posts, $wpdb->postmeta ), ARRAY_A ) ); $checks['no_alt'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(DISTINCT p.ID) AS n, SUBSTRING_INDEX(GROUP_CONCAT(p.ID ORDER BY p.post_date DESC), ',', 3) AS ids FROM %i p JOIN %i t ON t.post_id=p.ID AND t.meta_key='_thumbnail_id' AND t.meta_value NOT IN ('','0') WHERE p.post_type='product' AND p.post_status='publish' AND NOT EXISTS (SELECT 1 FROM %i a WHERE a.post_id=CAST(t.meta_value AS UNSIGNED) AND a.meta_key='_wp_attachment_image_alt' AND TRIM(a.meta_value)<>'')", $wpdb->posts, $wpdb->postmeta, $wpdb->postmeta ), ARRAY_A ) ); $checks['duplicate_title'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, SUBSTRING_INDEX(GROUP_CONCAT(p.ID ORDER BY p.post_title, p.post_date DESC), ',', 3) AS ids FROM %i p JOIN (SELECT LOWER(TRIM(post_title)) AS title FROM %i WHERE post_type='product' AND post_status='publish' AND TRIM(post_title)<>'' GROUP BY LOWER(TRIM(post_title)) HAVING COUNT(*)>1) d ON LOWER(TRIM(p.post_title))=d.title WHERE p.post_type='product' AND p.post_status='publish'", $wpdb->posts, $wpdb->posts ), ARRAY_A ) ); $checks['uncategorized'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, SUBSTRING_INDEX(GROUP_CONCAT(q.ID ORDER BY q.post_date DESC), ',', 3) AS ids FROM (SELECT p.ID, p.post_date FROM %i p LEFT JOIN %i r ON r.object_id=p.ID LEFT JOIN %i x ON x.term_taxonomy_id=r.term_taxonomy_id AND x.taxonomy='product_cat' WHERE p.post_type='product' AND p.post_status='publish' GROUP BY p.ID, p.post_date HAVING SUM(x.term_id IS NOT NULL AND x.term_id<>%d)=0) q", $wpdb->posts, $wpdb->term_relationships, $wpdb->term_taxonomy, (int) get_option( 'default_product_cat', 0 ) ), ARRAY_A ) ); $checks['long_title'] = self::row( $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS n, SUBSTRING_INDEX(GROUP_CONCAT(ID ORDER BY post_date DESC), ',', 3) AS ids FROM %i WHERE post_type='product' AND post_status='publish' AND CHAR_LENGTH(post_title)>%d", $wpdb->posts, 70 ), ARRAY_A ) ); } return array( 'version' => 1, 'at' => time(), 'total' => $total, 'checks' => $checks, ); } private static function row( $row ) : array { $row = ( is_array( $row ) ? $row : array() ); return array( 'n' => (int) ($row['n'] ?? 0), 'ids' => array_values( array_unique( array_filter( array_map( 'intval', explode( ',', (string) ($row['ids'] ?? '') ) ) ) ) ), ); } /** Whether something writes product meta descriptions: an SEO plugin, or StoreRuler Pro's own SEO output. */ private static function writes_meta() : bool { $active = array_merge( (array) get_option( 'active_plugins', array() ), array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); foreach ( self::SEO_PLUGINS as $constant => $folder ) { if ( defined( $constant ) ) { return true; } foreach ( $active as $file ) { if ( is_string( $file ) && str_starts_with( $file, $folder ) ) { return true; } } } $writes = false; return $writes; } /** The score, the issues (worst first) and the way to fix them, in the reader's language. */ public static function summary( array $found ) : array { $total = (int) ($found['total'] ?? 0); $penalty = 0.0; $issues = array(); $passed = 0; foreach ( self::WEIGHTS as $code => $weight ) { $check = (array) ($found['checks'][$code] ?? array()); $n = (int) ($check['n'] ?? 0); $site = in_array( $code, self::SITE, true ); if ( !$site && $total === 0 ) { continue; } if ( $n <= 0 ) { $passed++; continue; } $share = ( $site ? 1.0 : min( 1.0, $n / $total ) ); $cost = $weight * $share; $penalty += $cost; $examples = array(); foreach ( (array) ($check['ids'] ?? array()) as $id ) { $title = get_the_title( (int) $id ); if ( $title !== '' ) { $examples[] = array( 'id' => (int) $id, 'name' => html_entity_decode( wp_strip_all_tags( $title ), ENT_QUOTES, 'UTF-8' ), 'url' => admin_url( 'admin.php?page=storesuit-products&id=' . (int) $id ), ); } } $issues[] = array( 'code' => $code, 'level' => ( $code === 'hidden' ? 'critical' : (( $site || $share >= 0.5 ? 'warning' : 'notice' )) ), 'count' => ( $site ? 0 : $n ), 'total' => ( $site ? 0 : $total ), 'title' => I18n::t( self::TEXTS[$code][0] ), 'hint' => I18n::t( self::TEXTS[$code][1] ), 'examples' => $examples, 'cost' => round( $cost, 2 ), ); } usort( $issues, static fn( array $a, array $b ): int => $b['cost'] <=> $a['cost'] ); $score = (int) max( 0, min( 100, round( 100 - $penalty ) ) ); $fix = array( 'url' => ProPage::url() . '#ss-pro-compare', 'label' => I18n::t( 'Fix the issues with StoreRuler Pro' ), 'text' => I18n::t( 'Audit your store, fix titles and descriptions in bulk, add structured data and sitemaps, and follow Search Console and rankings.' ), ); return array( 'score' => $score, 'grade' => ( $score >= 85 ? 'good' : (( $score >= 60 ? 'fair' : 'poor' )) ), 'grade_label' => I18n::t( ( $score >= 85 ? 'Good' : (( $score >= 60 ? 'Needs work' : 'Poor' )) ) ), 'products' => $total, 'issues' => $issues, 'top' => array_slice( $issues, 0, 5 ), 'passed' => $passed, 'checked' => wp_date( (string) get_option( 'date_format' ) . ' ' . (string) get_option( 'time_format' ), (int) ($found['at'] ?? time()) ), 'fix' => $fix, ); } /** Forgets the six-hour copy (after an AI text was applied to a product), so the next summary counts again. */ public static function forget() : void { delete_transient( self::CACHE ); } }