false, 'rating_ld' => false, 'llms' => false, 'indexnow' => false, 'shipping_ld' => false, ) ); } /** * @param string $fix Fix id. * @return bool */ function sevenbe_fix_on( $fix ) { $fixes = sevenbe_fixes(); return ! empty( $fixes[ $fix ] ); } /** * An SEO plugin that prints its own product data, so ours would be a duplicate. * * @return string The plugin's name, or empty. */ function sevenbe_seo_product_data() { if ( defined( 'RANK_MATH_VERSION' ) ) { return 'Rank Math'; } if ( defined( 'WPSEO_WOO_VERSION' ) ) { return 'Yoast WooCommerce SEO'; } return ''; } // ---------- Product structured data ---------- add_action( 'wp_footer', 'sevenbe_print_product_data', 20 ); /** * Print Product JSON-LD on a product page, after WooCommerce's own (wp_footer, priority 10) would have printed. */ function sevenbe_print_product_data() { if ( ! sevenbe_fix_on( 'product_ld' ) || ! function_exists( 'is_product' ) || ! is_product() || sevenbe_seo_product_data() ) { return; } // WooCommerce built its own product data for this page, so there is nothing to add. if ( did_filter( 'woocommerce_structured_data_product' ) ) { return; } $product = wc_get_product( get_queried_object_id() ); if ( ! $product ) { return; } $data = sevenbe_product_data( $product ); echo '' . "\n"; } /** * @param WC_Product $product The product. * @return array schema.org Product. */ function sevenbe_product_data( $product ) { $url = get_permalink( $product->get_id() ); $description = $product->get_short_description() ? $product->get_short_description() : $product->get_description(); $data = array( '@context' => 'https://schema.org/', '@type' => 'Product', '@id' => $url . '#product', 'name' => wp_strip_all_tags( $product->get_name() ), 'url' => $url, 'description' => wp_html_excerpt( wp_strip_all_tags( strip_shortcodes( $description ) ), 5000 ), 'sku' => $product->get_sku() ? $product->get_sku() : (string) $product->get_id(), ); $images = array_filter( array_merge( array( $product->get_image_id() ), $product->get_gallery_image_ids() ) ); if ( $images ) { $data['image'] = array_values( array_filter( array_map( 'wp_get_attachment_url', array_slice( $images, 0, 5 ) ) ) ); } if ( taxonomy_exists( 'product_brand' ) ) { $brands = get_the_terms( $product->get_id(), 'product_brand' ); if ( is_array( $brands ) && $brands ) { $data['brand'] = array( '@type' => 'Brand', 'name' => $brands[0]->name, ); } } $offer = array( '@type' => 'Offer', 'url' => $url, 'priceCurrency' => get_woocommerce_currency(), 'availability' => $product->is_in_stock() ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock', ); if ( $product->is_type( 'variable' ) ) { $low = $product->get_variation_price( 'min', true ); $high = $product->get_variation_price( 'max', true ); if ( '' !== $low ) { $offer['@type'] = 'AggregateOffer'; $offer['lowPrice'] = wc_format_decimal( $low, wc_get_price_decimals() ); $offer['highPrice'] = wc_format_decimal( $high, wc_get_price_decimals() ); } } elseif ( '' !== $product->get_price() ) { $offer['price'] = wc_format_decimal( wc_get_price_to_display( $product ), wc_get_price_decimals() ); } $data['offers'] = function_exists( 'sevenbe_offer_terms' ) ? sevenbe_offer_terms( $offer ) : $offer; if ( wc_review_ratings_enabled() && $product->get_rating_count() > 0 ) { $data['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => $product->get_average_rating(), 'reviewCount' => $product->get_review_count() ? $product->get_review_count() : $product->get_rating_count(), ); } return sevenbe_add_rating( $data, $product ); } // ---------- The 7BE rating in product data ---------- /** * The store's 7BE reviews as the public API gives them: which features apply and each product's rating. * Cached for 6 hours; an hour after a failed call. Empty while the store is not connected. * * @return array { features: array, products: array } */ function sevenbe_ratings() { // The rating is 7BE's, rented with the service: not connected, no rating. if ( '' === sevenbe_options()['key'] ) { return array( 'features' => array(), 'products' => array(), ); } $cached = get_transient( 'sevenbe_ratings' ); if ( is_array( $cached ) ) { return $cached; } $domain = preg_replace( '/^www\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $response = wp_remote_get( SEVENBE_API . '/api/reviews/' . rawurlencode( $domain ) . '?limit=0', array( 'timeout' => 5 ) ); $data = is_wp_error( $response ) ? null : json_decode( wp_remote_retrieve_body( $response ), true ); $out = array( 'features' => is_array( $data ) && isset( $data['features'] ) && is_array( $data['features'] ) ? $data['features'] : array(), 'products' => is_array( $data ) && isset( $data['products'] ) && is_array( $data['products'] ) ? $data['products'] : array(), ); set_transient( 'sevenbe_ratings', $out, is_array( $data ) ? 6 * HOUR_IN_SECONDS : HOUR_IN_SECONDS ); return $out; } /** * A product's 7BE rating: matched by GTIN, SKU or its address. Null when the fix is off, 7BE does not give * ratings on product pages for this store, or the product has no rating yet. * * @param WC_Product $product The product. * @return array|null { average: float, count: int } */ function sevenbe_rating_for( $product ) { if ( ! sevenbe_fix_on( 'rating_ld' ) || ! $product ) { return null; } $ratings = sevenbe_ratings(); if ( empty( $ratings['features']['ratingOnPages'] ) ) { return null; } if ( $product->get_parent_id() ) { $product = wc_get_product( $product->get_parent_id() ); } $gtin = method_exists( $product, 'get_global_unique_id' ) ? (string) $product->get_global_unique_id() : ''; $sku = strtolower( (string) $product->get_sku() ); $path = untrailingslashit( (string) wp_parse_url( get_permalink( $product->get_id() ), PHP_URL_PATH ) ); foreach ( $ratings['products'] as $row ) { $row_path = isset( $row['url'] ) ? untrailingslashit( (string) wp_parse_url( $row['url'], PHP_URL_PATH ) ) : ''; $match = ( '' !== $gtin && isset( $row['gtin'] ) && $row['gtin'] === $gtin ) || ( '' !== $sku && isset( $row['sku'] ) && strtolower( $row['sku'] ) === $sku ) || ( '' !== $path && $row_path === $path ); if ( $match && ! empty( $row['count'] ) ) { return array( 'average' => (float) $row['average'], 'count' => (int) $row['count'], ); } } return null; } /** * Add the 7BE rating to Product data that has none. A rating already there (WooCommerce's own reviews) stays. * * @param array $data schema.org Product. * @param WC_Product $product The product. * @return array */ function sevenbe_add_rating( $data, $product ) { if ( ! is_array( $data ) || ! empty( $data['aggregateRating'] ) ) { return $data; } $rating = sevenbe_rating_for( $product ); if ( $rating ) { $data['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => $rating['average'], 'ratingCount' => $rating['count'], 'bestRating' => 5, 'worstRating' => 1, ); } return $data; } /** * The product on the current product page, for the SEO plugins' filters, which do not pass it. * * @return WC_Product|null */ function sevenbe_current_product() { return function_exists( 'is_product' ) && is_product() ? wc_get_product( get_queried_object_id() ) : null; } // WooCommerce's own product data, Rank Math's and Yoast WooCommerce SEO's. add_filter( 'woocommerce_structured_data_product', 'sevenbe_add_rating', 20, 2 ); add_filter( 'rank_math/snippet/rich_snippet_product_entity', function ( $entity ) { return sevenbe_add_rating( $entity, sevenbe_current_product() ); }, 20 ); add_filter( 'wpseo_schema_product', function ( $data ) { return sevenbe_add_rating( $data, sevenbe_current_product() ); }, 20 ); // ---------- llms.txt ---------- add_action( 'parse_request', 'sevenbe_serve_llms_txt', 0 ); /** * Answer /llms.txt. A real llms.txt file in the site's root is served by the web server before WordPress runs. * * @param WP $wp The request. */ function sevenbe_serve_llms_txt( $wp ) { if ( 'llms.txt' !== $wp->request || ! sevenbe_fix_on( 'llms' ) ) { return; } $text = get_transient( 'sevenbe_llms_txt' ); if ( false === $text ) { $text = sevenbe_llms_txt(); set_transient( 'sevenbe_llms_txt', $text, HOUR_IN_SECONDS ); } status_header( 200 ); header( 'Content-Type: text/plain; charset=utf-8' ); echo $text; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a plain-text response built from stripped titles and URLs. exit; } /** * @param string $text HTML or text. * @param int $max Longest length. * @return string One line of plain text. */ function sevenbe_line( $text, $max = 200 ) { $text = trim( preg_replace( '/\s+/', ' ', wp_strip_all_tags( strip_shortcodes( (string) $text ) ) ) ); return wp_html_excerpt( html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ), $max, '…' ); } /** * @param string $title Link text. * @param string $url Address. * @param string $note Optional description. * @return string A Markdown list item. */ function sevenbe_md_link( $title, $url, $note = '' ) { $title = str_replace( array( '[', ']' ), '', sevenbe_line( $title, 120 ) ); return '- [' . $title . '](' . esc_url_raw( $url ) . ')' . ( '' !== $note ? ': ' . $note : '' ) . "\n"; } /** * @return string The store described in Markdown, as llmstxt.org describes. */ function sevenbe_llms_txt() { $name = sevenbe_line( get_bloginfo( 'name' ), 120 ); $tagline = sevenbe_line( get_bloginfo( 'description' ), 200 ); $out = '# ' . $name . "\n\n"; $out .= '> ' . ( '' !== $tagline ? $tagline : sprintf( 'The online store %s.', $name ) ) . "\n\n"; $out .= sprintf( '%1$s sells online at %2$s. Prices are in %3$s.', $name, home_url( '/' ), get_woocommerce_currency() ) . "\n\n"; $categories = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', 'number' => 30, ) ); if ( is_array( $categories ) && $categories ) { $out .= "## Product categories\n\n"; foreach ( $categories as $category ) { if ( 'uncategorized' === $category->slug ) { continue; } $link = get_term_link( $category ); if ( ! is_wp_error( $link ) ) { $out .= sevenbe_md_link( $category->name, $link, sevenbe_line( $category->description, 160 ) ); } } $out .= "\n"; } $products = wc_get_products( array( 'status' => 'publish', 'visibility' => 'catalog', 'limit' => 20, 'orderby' => 'meta_value_num', 'meta_key' => 'total_sales', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- 20 best sellers, cached for an hour. 'order' => 'DESC', ) ); if ( $products ) { $out .= "## Popular products\n\n"; foreach ( $products as $product ) { $about = $product->get_short_description() ? $product->get_short_description() : $product->get_description(); $out .= sevenbe_md_link( $product->get_name(), get_permalink( $product->get_id() ), sevenbe_line( $about, 160 ) ); } $shop = wc_get_page_permalink( 'shop' ); if ( $shop ) { $out .= sevenbe_md_link( 'All products', $shop ); } $out .= "\n"; } $policies = array(); $refunds = (int) get_option( 'woocommerce_refund_returns_page_id' ); if ( $refunds && 'publish' === get_post_status( $refunds ) ) { $policies[] = sevenbe_md_link( get_the_title( $refunds ), get_permalink( $refunds ) ); } $terms = wc_terms_and_conditions_page_id(); if ( $terms && 'publish' === get_post_status( $terms ) ) { $policies[] = sevenbe_md_link( get_the_title( $terms ), get_permalink( $terms ) ); } if ( get_privacy_policy_url() ) { $policies[] = sevenbe_md_link( __( 'Privacy policy', '7be-ai-shelf' ), get_privacy_policy_url() ); } if ( $policies ) { $out .= "## Policies\n\n" . implode( '', $policies ); } return $out; } // ---------- The panel on the plugin page ---------- add_action( 'admin_post_sevenbe_fix', function () { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You are not allowed to do this.', '7be-ai-shelf' ) ); } check_admin_referer( 'sevenbe_fix' ); $fix = isset( $_POST['fix'] ) ? sanitize_key( wp_unslash( $_POST['fix'] ) ) : ''; $fixes = sevenbe_fixes(); if ( array_key_exists( $fix, $fixes ) ) { $fixes[ $fix ] = ! empty( $_POST['on'] ); update_option( SEVENBE_FIXES_OPTION, $fixes, true ); delete_transient( 'sevenbe_llms_txt' ); delete_transient( 'sevenbe_ratings' ); } wp_safe_redirect( sevenbe_page_url( array( 'sevenbe' => 'fixes' ) ) . '#sevenbe-fixes' ); exit; } ); /** * The one-click fixes panel; the 7BE dashboard links here (#sevenbe-fixes). */ function sevenbe_render_fixes() { $fixes = sevenbe_fixes(); $seo = sevenbe_seo_product_data(); $ratings = sevenbe_ratings(); $rows = array( 'product_ld' => array( 'title' => __( 'Product data on product pages', '7be-ai-shelf' ), 'what' => __( 'Adds product structured data (name, images, price, stock and ratings from your reviews) to product pages where WooCommerce does not add its own.', '7be-ai-shelf' ), 'note' => $seo /* translators: %s: an SEO plugin's name. */ ? sprintf( __( '%s already adds product data to your pages, so this fix stays idle.', '7be-ai-shelf' ), $seo ) : '', ), 'rating_ld' => array( 'title' => __( 'Your 7BE rating in product data', '7be-ai-shelf' ), 'what' => __( 'Adds each product\'s rating from 7BE verified reviews to the product data on its page, where search engines and AI assistants read it. A rating from your own WooCommerce reviews stays as it is. Works with WooCommerce\'s product data, Rank Math and Yoast WooCommerce SEO.', '7be-ai-shelf' ), 'note' => empty( $ratings['features']['ratingOnPages'] ) ? __( '7BE does not give this store a rating for product pages yet, so this fix stays idle.', '7be-ai-shelf' ) : ( $ratings['products'] ? '' : __( 'No product has a 7BE rating yet. Ratings appear here as buyers rate products in their reviews.', '7be-ai-shelf' ) ), ), 'llms' => array( 'title' => __( 'llms.txt', '7be-ai-shelf' ), 'what' => __( 'Publishes a page for AI assistants at /llms.txt: your store, categories, popular products and policies, with links.', '7be-ai-shelf' ), 'note' => file_exists( ABSPATH . 'llms.txt' ) ? __( 'Your site already has an llms.txt file, and it is shown instead.', '7be-ai-shelf' ) : '', ), 'indexnow' => array( 'title' => __( 'Instant indexing (IndexNow)', '7be-ai-shelf' ), 'what' => __( 'When you publish or change a product, tells IndexNow, which Bing and Microsoft Copilot use, so they read the new page within hours instead of weeks. Only the page address is sent.', '7be-ai-shelf' ), 'note' => sevenbe_other_indexnow() /* translators: %s: a plugin's name. */ ? sprintf( __( '%s already sends IndexNow for your site, so this fix stays idle.', '7be-ai-shelf' ), sevenbe_other_indexnow() ) : '', ), 'shipping_ld' => array( 'title' => __( 'Delivery and returns in product data', '7be-ai-shelf' ), 'what' => __( 'Adds your delivery price and times and your returns terms, as you confirmed them in your 7BE dashboard (Product feeds, Delivery and returns), to the product data on product pages, where Google and AI assistants read them.', '7be-ai-shelf' ), 'note' => empty( sevenbe_feed_config()['shipping'] ) ? __( 'Confirm your delivery and returns in your 7BE dashboard first (Product feeds, Delivery and returns); until then this fix stays idle.', '7be-ai-shelf' ) : '', ), ); echo '
'; echo '

' . esc_html__( 'One-click fixes', '7be-ai-shelf' ) . '

'; echo '

' . esc_html__( 'Changes that help AI assistants read your store. Your products and theme files stay as they are, and you can switch a fix off at any time. The next scan checks the result.', '7be-ai-shelf' ) . '

'; echo ''; foreach ( $rows as $fix => $row ) { $on = ! empty( $fixes[ $fix ] ); echo ''; } echo '
' . esc_html( $row['title'] ) . ' '; echo $on ? '' . esc_html__( 'On', '7be-ai-shelf' ) . '' : '' . esc_html__( 'Off', '7be-ai-shelf' ) . ''; echo '
' . esc_html( $row['what'] ); if ( $row['note'] ) { echo '
' . esc_html( $row['note'] ) . ''; } if ( 'llms' === $fix && $on ) { echo '
' . esc_html__( 'View llms.txt', '7be-ai-shelf' ) . ''; } echo '
'; echo '
'; echo ''; if ( ! $on ) { echo ''; } wp_nonce_field( 'sevenbe_fix' ); submit_button( $on ? __( 'Turn off', '7be-ai-shelf' ) : __( 'Turn on', '7be-ai-shelf' ), $on ? 'secondary' : 'primary', 'submit', false ); echo '
'; if ( ! get_option( 'permalink_structure' ) && ! empty( $fixes['llms'] ) ) { echo '

' . esc_html__( 'llms.txt needs pretty permalinks (Settings → Permalinks) on most servers.', '7be-ai-shelf' ) . '

'; } echo '
'; }