post_type, array( 'page', 'post', 'product', 'product_variation' ), true ) ) { return; } if ( ! Clientager_Api::token() || wp_next_scheduled( self::EVENT ) ) { return; } if ( empty( self::known_status()['auto_update'] ) ) { return; } wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::EVENT ); } /** * The last account status this site saw, without asking Clientager (saving a post never waits on the network). * * @return array */ private static function known_status() { $hit = get_transient( Clientager_Api::STATUS_CACHE ); if ( is_array( $hit ) ) { return $hit; } $last = get_option( Clientager_Api::OPT_LAST_STATUS ); return is_array( $last ) && isset( $last['status'] ) && is_array( $last['status'] ) ? $last['status'] : array(); } /** * The scheduled learn. */ public static function auto_run() { $s = Clientager_Api::status( true ); if ( ! empty( $s['auto_update'] ) ) { self::run(); } } /** * Pages and products this plan learns (from the account status; the smallest plan's when unknown). * * @return array{pages:int,products:int} */ private static function limits() { $s = Clientager_Api::status( true ); $l = isset( $s['learn_limits'] ) && is_array( $s['learn_limits'] ) ? $s['learn_limits'] : array(); return array( 'pages' => isset( $l['pages'] ) ? max( 1, min( 500, (int) $l['pages'] ) ) : self::MAX_PAGES, 'products' => isset( $l['products'] ) ? max( 1, min( 5000, (int) $l['products'] ) ) : self::MAX_PRODUCTS, ); } /** * Sends the site's content to Clientager. * * @return array API result. */ public static function run() { $payload = self::payload(); $res = Clientager_Api::request( 'POST', '/learn', $payload, array( 'timeout' => 60 ) ); if ( $res['ok'] ) { Clientager_Api::clear_status(); } return $res; } /** * The full payload. * * @return array */ public static function payload() { return array( 'site' => self::site(), 'pages' => self::pages(), 'products' => self::products(), ); } /** * Basic facts about the site. * * @return array */ private static function site() { return array( 'name' => wp_strip_all_tags( get_bloginfo( 'name' ) ), 'tagline' => wp_strip_all_tags( get_bloginfo( 'description' ) ), 'url' => home_url( '/' ), 'timezone' => wp_timezone_string(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', 'language' => get_locale(), 'admin_email' => get_option( 'admin_email' ), ); } /** * Published pages first, then the latest posts, up to the plan's number, as plain text. * * @return array */ private static function pages() { $out = array(); $max = self::limits()['pages']; $chars = max( 1500, min( self::MAX_CONTENT, (int) floor( self::MAX_BYTES / $max ) ) ); $pages = get_posts( array( 'post_type' => 'page', 'post_status' => 'publish', 'has_password' => false, 'numberposts' => $max, 'orderby' => 'menu_order title', 'order' => 'ASC', 'suppress_filters' => false, ) ); $left = $max - count( $pages ); $posts = $left > 0 ? get_posts( array( 'post_type' => 'post', 'post_status' => 'publish', 'has_password' => false, 'numberposts' => $left, 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => false, ) ) : array(); foreach ( array_merge( $pages, $posts ) as $p ) { $text = self::plain( $p->post_content ); if ( strlen( $text ) < 40 ) { continue; } $out[] = array( 'title' => wp_strip_all_tags( get_the_title( $p ) ), 'url' => get_permalink( $p ), 'content' => self::cut( $text, $chars ), ); } return $out; } /** * Published WooCommerce products (when WooCommerce is active). * * @return array */ private static function products() { if ( ! function_exists( 'wc_get_products' ) ) { return array(); } $out = array(); $currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : ''; $items = wc_get_products( array( 'status' => 'publish', 'limit' => self::limits()['products'], 'orderby' => 'popularity', ) ); foreach ( $items as $product ) { $desc = $product->get_short_description() ? $product->get_short_description() : $product->get_description(); $price = $product->get_price(); $out[] = array( 'id' => (string) $product->get_id(), 'name' => wp_strip_all_tags( $product->get_name() ), 'description' => self::cut( self::plain( $desc ), 1000 ), 'price' => '' === $price ? null : (float) $price, 'currency' => $currency, 'sku' => (string) $product->get_sku(), 'url' => get_permalink( $product->get_id() ), 'image' => $product->get_image_id() ? (string) wp_get_attachment_image_url( $product->get_image_id(), 'woocommerce_thumbnail' ) : '', 'simple' => $product->is_type( 'simple' ) && $product->is_purchasable(), 'category' => self::category( $product ), 'options' => self::options( $product ), 'tags' => self::tags( $product ), 'in_stock' => $product->is_in_stock(), ); } return $out; } /** * Product tags as text ("cotton, summer, unisex"), more words the chat can match. * * @param WC_Product $product Product. * @return string */ private static function tags( $product ) { $terms = get_the_terms( $product->get_id(), 'product_tag' ); if ( ! is_array( $terms ) ) { return ''; } $names = array(); foreach ( $terms as $term ) { $names[] = wp_strip_all_tags( $term->name ); } return self::cut( implode( ', ', $names ), 300 ); } /** * Product attributes as text ("Color: Gray, Maroon; Size: S, M"), so the chat can answer "any in grey?". * * @param WC_Product $product Product. * @return string */ private static function options( $product ) { $parts = array(); foreach ( $product->get_attributes() as $attr ) { if ( ! is_object( $attr ) || ! method_exists( $attr, 'get_name' ) ) { continue; } $values = $attr->is_taxonomy() ? wc_get_product_terms( $product->get_id(), $attr->get_name(), array( 'fields' => 'names' ) ) : $attr->get_options(); if ( $values ) { $parts[] = wc_attribute_label( $attr->get_name() ) . ': ' . implode( ', ', array_map( 'wp_strip_all_tags', (array) $values ) ); } } return self::cut( implode( '; ', $parts ), 400 ); } /** * The product's main category name (the first one that is not "Uncategorized"), for the chat's category overview. * * @param WC_Product $product Product. * @return string */ private static function category( $product ) { $terms = get_the_terms( $product->get_id(), 'product_cat' ); if ( ! is_array( $terms ) ) { return ''; } foreach ( $terms as $term ) { if ( 'uncategorized' !== $term->slug ) { return self::cut( wp_strip_all_tags( $term->name ), 60 ); } } return ''; } /** * Post content as plain text: no shortcodes, blocks markup, scripts, styles or tags. * * @param string $html Raw content. * @return string */ private static function plain( $html ) { $html = strip_shortcodes( (string) $html ); $html = preg_replace( '#<(script|style|noscript|iframe|svg)[^>]*>.*?#is', ' ', $html ); $html = preg_replace( '##s', ' ', $html ); $html = preg_replace( '#|#i', "\n", $html ); $text = wp_strip_all_tags( $html ); $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( '/\[[^\]]{0,200}\]/', ' ', $text ); $text = preg_replace( "/[ \t\x{00A0}]+/u", ' ', $text ); $text = preg_replace( "/\s*\n\s*/", "\n", $text ); return trim( (string) $text ); } /** * Cuts a string to a number of characters without breaking a multibyte character. * * @param string $text Text. * @param int $max Characters. * @return string */ private static function cut( $text, $max ) { return function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $max ) : substr( $text, 0, $max ); } }