'About us', 'getting-started' => 'Getting started', 'services' => 'Services', 'pricing' => 'Prices and plans', 'products' => 'Products', 'policies' => 'Policies', 'contact' => 'Contact', ); public static function hooks() { add_action( 'replyvane_kb_scan_complete', array( __CLASS__, 'maybe_auto' ) ); } /** After the first scan (or when asked), create questions automatically. */ public static function maybe_auto() { if ( get_option( 'replyvane_autogen_pending' ) ) { delete_option( 'replyvane_autogen_pending' ); $report = self::generate(); update_option( 'replyvane_autogen_report', $report, false ); } } /* ------------------------------------------------------------------ */ /* Reading helpers */ /* ------------------------------------------------------------------ */ private static function pages() { global $wpdb; $table = Replyvane_Knowledge::table(); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, url, MIN(title) AS title FROM %i GROUP BY post_id, url ORDER BY MIN(id)", $table ), ARRAY_A ); $pages = array(); foreach ( (array) $rows as $r ) { // The page's own path ("about-us"), which works with any permalink setting. $uri = get_page_uri( (int) $r['post_id'] ); $path = trim( strtolower( $uri ? $uri : (string) wp_parse_url( $r['url'], PHP_URL_PATH ) ), '/' ); $r['path'] = $path; $r['type'] = get_post_type( $r['post_id'] ); $r['match_text'] = strtolower( $path . ' ' . $r['title'] ); $pages[] = $r; } return $pages; } private static function page_text( $post_id ) { global $wpdb; $table = Replyvane_Knowledge::table(); $rows = $wpdb->get_col( $wpdb->prepare( "SELECT content FROM %i WHERE post_id = %d ORDER BY id", $table, $post_id ) ); return implode( "\n", (array) $rows ); } /** Lines of a page without section markers or link addresses. */ private static function lines( $text ) { $out = array(); foreach ( explode( "\n", (string) $text ) as $line ) { $line = trim( preg_replace( '/\s*\[https?:[^\]]+\]/', '', $line ) ); if ( '' === $line || 0 === strpos( $line, '## ' ) || '(continued)' === $line ) { continue; } $out[] = $line; } return array_values( array_unique( $out ) ); } private static function headings( $post_id ) { global $wpdb; $table = Replyvane_Knowledge::table(); $out = array(); foreach ( (array) $wpdb->get_col( $wpdb->prepare( "SELECT content FROM %i WHERE post_id = %d ORDER BY id", $table, $post_id ) ) as $content ) { foreach ( explode( "\n", $content ) as $line ) { if ( 0 === strpos( $line, '## ' ) ) { $parts = explode( ' › ', substr( $line, 3 ) ); $h = trim( end( $parts ) ); if ( '' !== $h && mb_strlen( $h ) <= 70 && '?' !== mb_substr( $h, -1 ) ) { $out[ $h ] = true; } } } } return array_keys( $out ); } /** The first real sentences of a page (skips short labels and buttons). */ private static function intro( $text, $max = 600 ) { $picked = array(); $length = 0; foreach ( self::lines( $text ) as $line ) { if ( mb_strlen( $line ) < 60 || 0 === strpos( $line, '- ' ) ) { continue; } $picked[] = $line; $length += mb_strlen( $line ); if ( $length >= $max || count( $picked ) >= 3 ) { break; } } $text = implode( "\n\n", $picked ); return mb_strlen( $text ) > $max + 200 ? rtrim( mb_substr( $text, 0, $max ) ) . '…' : $text; } /** * Lines that mention prices, labelled with the plan or product name they * sit under ("Starter: $89 / month – Up to 8 classes a month."). */ private static function price_lines( $text, $page_title = '' ) { $raw = explode( "\n", (string) $text ); $out = array(); $heading = ''; $prev = ''; $price = '/[$€£₹]\s?\d|\d\s?(usd|eur|gbp|inr)\b|per (month|year|session|hour)|\/\s?(mo|month|yr|year)\b/i'; $count = count( $raw ); for ( $i = 0; $i < $count; $i++ ) { $line = trim( preg_replace( '/\s*\[https?:[^\]]+\]/', '', $raw[ $i ] ) ); if ( '' === $line || '(continued)' === $line ) { continue; } if ( 0 === strpos( $line, '## ' ) ) { $parts = explode( ' › ', substr( $line, 3 ) ); $heading = trim( end( $parts ) ); $prev = ''; continue; } if ( ! preg_match( $price, $line ) ) { $prev = $line; continue; } $item = ltrim( $line, '- ' ); if ( mb_strlen( $item ) < 30 ) { // A bare price: label it with its heading, or the short line above it. $label = ''; if ( $heading && 0 !== strcasecmp( $heading, $page_title ) && ! preg_match( '/\d/', $heading ) ) { $label = $heading; } elseif ( $prev && mb_strlen( $prev ) < 40 && ! preg_match( '/\d|[.!?]$/', $prev ) ) { $label = $prev; } if ( $label ) { $item = rtrim( $label, ':' ) . ': ' . $item; } // Add the short description that follows, if there is one. $next = isset( $raw[ $i + 1 ] ) ? trim( $raw[ $i + 1 ] ) : ''; if ( $next && 0 !== strpos( $next, '## ' ) && mb_strlen( $next ) < 90 && ! preg_match( $price, $next ) ) { $item .= ' – ' . $next; $i++; } } $item = mb_substr( $item, 0, 180 ); if ( ! in_array( $item, $out, true ) ) { $out[] = $item; } $prev = $line; if ( count( $out ) >= 12 ) { break; } } return $out; } private static function contact_details( $text ) { preg_match_all( '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', $text, $emails ); $phones = array(); if ( preg_match_all( '/(?= 9 && strlen( $digits ) <= 15 && ! preg_match( '/^(19|20)\d{2}[\-\/.]/', trim( $candidate ) ) ) { $phones[] = trim( $candidate ); } } } $hours = array(); foreach ( self::lines( $text ) as $line ) { if ( mb_strlen( $line ) < 120 && preg_match( '/\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*\b|\bweekdays?\b|\bdaily\b/i', $line ) && preg_match( '/\d{1,2}(:\d{2})?\s*(am|pm)|\b\d{1,2}:\d{2}\b|\bclosed\b/i', $line ) ) { $hours[] = ltrim( $line, '- ' ); } } return array( 'emails' => array_slice( array_values( array_unique( array_map( 'strtolower', $emails[0] ) ) ), 0, 3 ), 'phones' => array_slice( array_values( array_unique( $phones ) ), 0, 3 ), 'hours' => array_slice( array_values( array_unique( $hours ) ), 0, 8 ), ); } /** First page whose address or title contains one of the words (shortest address wins). */ private static function find_page( array $pages, array $words ) { $found = null; foreach ( $pages as $p ) { if ( 'page' !== $p['type'] ) { continue; } foreach ( $words as $w ) { if ( preg_match( '/(^|[^a-z])' . preg_quote( $w, '/' ) . '/', $p['match_text'] ) ) { if ( ! $found || strlen( $p['path'] ) < strlen( $found['path'] ) ) { $found = $p; } break; } } } return $found; } private static function button( $page, $label = '' ) { return array( array( 'label' => mb_substr( $label ? $label : $page['title'], 0, 60 ), 'url' => $page['url'], ), ); } /* ------------------------------------------------------------------ */ /* Building the questions */ /* ------------------------------------------------------------------ */ private static function build() { $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); $pages = self::pages(); $items = array(); $topic = array(); $add = function ( $id, $cat, $question, $answer, $args = array() ) use ( &$items ) { if ( '' === trim( (string) $answer ) ) { return; } $items[ $id ] = array_merge( array( 'id' => 'auto-' . $id, 'category' => $cat, 'question' => $question, 'answer' => mb_substr( trim( $answer ), 0, 1800 ), 'keywords' => array(), 'links' => array(), 'featured' => false, 'order' => count( $items ) + 1, ), $args ); }; // Home page: what the business does. $front = (int) get_option( 'page_on_front' ); $home = null; foreach ( $pages as $p ) { if ( ( $front && (int) $p['post_id'] === $front ) || untrailingslashit( $p['url'] ) === untrailingslashit( home_url( '/' ) ) ) { $home = $p; break; } } if ( $home ) { $tagline = trim( wp_specialchars_decode( get_bloginfo( 'description' ), ENT_QUOTES ) ); $intro = self::intro( self::page_text( $home['post_id'] ) ); $add( 'what-we-do', 'about', "What does {$site} do?", ( $tagline ? $tagline . "\n\n" : '' ) . $intro, array( 'featured' => true, 'keywords' => array( 'about', 'overview', 'introduction' ) ) ); } // About page. $about = self::find_page( $pages, array( 'about', 'our-story', 'who-we-are', 'our story', 'who we are' ) ); if ( $about ) { $add( 'about', 'about', "Tell me about {$site}", self::intro( self::page_text( $about['post_id'] ), 700 ), array( 'links' => self::button( $about ), 'keywords' => array( 'about', 'story', 'history', 'mission', 'founded', 'founder' ) ) ); } // Services. $services = self::find_page( $pages, array( 'services', 'service', 'what-we-do', 'treatments', 'solutions' ) ); if ( $services ) { $title = $services['title']; $heads = array_slice( array_values( array_filter( self::headings( $services['post_id'] ), function ( $h ) use ( $title ) { return 0 !== strcasecmp( $h, $title ); } ) ), 0, 10 ); $answer = self::intro( self::page_text( $services['post_id'] ), 60 ); if ( $heads ) { $answer .= ( $answer ? "\n\n" : '' ) . '- ' . implode( "\n- ", $heads ); } $add( 'services', 'services', 'What services do you offer?', $answer, array( 'featured' => true, 'links' => self::button( $services ), 'keywords' => array( 'services', 'offer', 'provide', 'options', 'treatments' ) ) ); } // Prices and plans. $pricing = self::find_page( $pages, array( 'pricing', 'prices', 'price', 'plans', 'plan', 'membership', 'packages', 'subscription', 'rates', 'fees' ) ); $price_source = $pricing ? $pricing : $home; if ( $price_source ) { $prices = self::price_lines( self::page_text( $price_source['post_id'] ), $price_source['title'] ); if ( $prices ) { $add( 'prices', 'pricing', $pricing ? 'What are your prices and plans?' : 'How much does it cost?', '- ' . implode( "\n- ", $prices ), array( 'featured' => true, 'links' => $pricing ? self::button( $pricing ) : array(), 'keywords' => array( 'price', 'prices', 'cost', 'how much', 'plans', 'fees', 'rates' ) ) ); } } // Booking. $booking = self::find_page( $pages, array( 'book', 'booking', 'appointment', 'schedule', 'reserve' ) ); if ( $booking ) { $add( 'booking', 'getting-started', 'How do I book?', self::intro( self::page_text( $booking['post_id'] ), 450 ) ?: "You can book on our {$booking['title']} page.", array( 'featured' => true, 'links' => self::button( $booking, 'Book now' ), 'keywords' => array( 'book', 'booking', 'appointment', 'schedule', 'reserve', 'start' ) ) ); } // Policies. $policies = array( 'refunds' => array( array( 'refund', 'return', 'cancellation', 'cancel' ), 'What is your refund and cancellation policy?', array( 'refund', 'return', 'cancel', 'money back', 'cancellation' ), true ), 'shipping' => array( array( 'shipping', 'delivery' ), 'How does shipping work?', array( 'shipping', 'delivery', 'ship', 'postage', 'arrive' ), false ), 'privacy' => array( array( 'privacy' ), 'How do you handle my personal information?', array( 'privacy', 'data', 'personal information', 'gdpr' ), false ), 'terms' => array( array( 'terms', 'conditions' ), 'Where can I read your terms and conditions?', array( 'terms', 'conditions', 'agreement' ), false ), ); foreach ( $policies as $id => $cfg ) { $page = self::find_page( $pages, $cfg[0] ); if ( $page ) { $len = 'terms' === $id || 'privacy' === $id ? 300 : 650; $answer = self::intro( self::page_text( $page['post_id'] ), $len ); $answer = ( $answer ? $answer . "\n\n" : '' ) . "You can read the full details on our [{$page['title']}]({$page['url']}) page."; $add( $id, 'policies', $cfg[1], $answer, array( 'featured' => $cfg[3], 'links' => self::button( $page ), 'keywords' => $cfg[2] ) ); } } // Contact details and hours. $contact = self::find_page( $pages, array( 'contact', 'get-in-touch', 'reach-us', 'locations', 'location' ) ); $text = $contact ? self::page_text( $contact['post_id'] ) : ( $home ? self::page_text( $home['post_id'] ) : '' ); $details = self::contact_details( $text ); $lines = array(); foreach ( $details['emails'] as $e ) { $lines[] = "- Email: [{$e}](mailto:{$e})"; } foreach ( $details['phones'] as $ph ) { $lines[] = '- Phone: [' . $ph . '](tel:' . preg_replace( '/[^\d+]/', '', $ph ) . ')'; } if ( $contact ) { $lines[] = "- Or send us a message on our [{$contact['title']}]({$contact['url']}) page."; } if ( $lines ) { $add( 'contact', 'contact', 'How can I contact you?', "You can reach us here:\n" . implode( "\n", $lines ), array( 'featured' => true, 'links' => $contact ? self::button( $contact, 'Contact us' ) : array(), 'keywords' => array( 'contact', 'email', 'phone', 'call', 'reach', 'message', 'support' ) ) ); } if ( $details['hours'] ) { $add( 'hours', 'contact', 'What are your opening hours?', '- ' . implode( "\n- ", $details['hours'] ), array( 'links' => $contact ? self::button( $contact ) : array(), 'keywords' => array( 'hours', 'open', 'opening', 'times', 'closed', 'when' ) ) ); } // Shop products (WooCommerce). if ( function_exists( 'wc_get_product' ) ) { $products = array(); foreach ( $pages as $p ) { if ( 'product' === $p['type'] && count( $products ) < self::MAX_PRODUCTS ) { $product = wc_get_product( $p['post_id'] ); if ( $product && $product->is_visible() ) { $products[] = array( $p, $product ); } } } $clean = function ( $html ) { return trim( preg_replace( '/\s+/u', ' ', html_entity_decode( wp_strip_all_tags( (string) $html ), ENT_QUOTES, 'UTF-8' ) ) ); }; if ( $products ) { $list = array(); foreach ( array_slice( $products, 0, 12 ) as $pp ) { $price = $clean( $pp[1]->get_price_html() ); $list[] = '- [' . $clean( $pp[1]->get_name() ) . '](' . $pp[0]['url'] . ')' . ( $price ? ' – ' . $price : '' ); } $shop = function_exists( 'wc_get_page_permalink' ) ? wc_get_page_permalink( 'shop' ) : ''; $add( 'products', 'products', 'What products do you sell?', implode( "\n", $list ), array( 'featured' => ! isset( $items['prices'] ), 'links' => $shop ? array( array( 'label' => 'Visit the shop', 'url' => $shop ) ) : array(), 'keywords' => array( 'products', 'shop', 'buy', 'sell', 'store', 'purchase' ) ) ); foreach ( $products as $pp ) { $name = $clean( $pp[1]->get_name() ); $price = $clean( $pp[1]->get_price_html() ); $summary = mb_substr( $clean( $pp[1]->get_short_description() ), 0, 400 ); if ( ! $price && ! $summary ) { continue; } $answer = ( $price ? "**{$name}** costs {$price}." : "**{$name}**" ) . ( $summary ? "\n\n" . $summary : '' ); $add( 'product-' . $pp[0]['post_id'], 'products', "Tell me about {$name}", $answer, array( 'links' => self::button( $pp[0], 'View product' ), 'keywords' => array_merge( array( 'price', 'cost', 'how much' ), explode( ' ', mb_strtolower( $name ) ) ) ) ); } } } // Questions and answers already written on the site. global $wpdb; $harvest = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id LIMIT %d', Replyvane_Knowledge::harvest_table(), self::MAX_HARVESTED ), ARRAY_A ); foreach ( (array) $harvest as $h ) { $cat = 'faq-' . sanitize_title( $h['page_title'] ); if ( ! isset( $topic[ $cat ] ) ) { $topic[ $cat ] = mb_substr( $h['page_title'], 0, 60 ); } $add( 'qa-' . substr( md5( $h['question'] . $h['post_id'] ), 0, 10 ), $cat, $h['question'], $h['answer'], array( 'links' => array( array( 'label' => mb_substr( $h['page_title'], 0, 60 ), 'url' => $h['url'] ) ) ) ); } // Related questions: others in the same topic. $by_cat = array(); foreach ( $items as $key => $item ) { $by_cat[ $item['category'] ][] = $item['id']; } foreach ( $items as $key => $item ) { $items[ $key ]['related'] = array_slice( array_values( array_diff( $by_cat[ $item['category'] ], array( $item['id'] ) ) ), 0, 3 ); } // Keep at most six popular questions. $featured = 0; foreach ( $items as $key => $item ) { if ( $item['featured'] && ++$featured > 6 ) { $items[ $key ]['featured'] = false; } } return array( array_values( $items ), $topic ); } /* ------------------------------------------------------------------ */ /* Saving */ /* ------------------------------------------------------------------ */ /** * Creates or refreshes generated questions. Questions you added or edited * yourself are left exactly as they are. */ public static function generate() { list( $items, $extra_topics ) = self::build(); $report = array( 'added' => 0, 'updated' => 0, 'kept' => 0, 'removed' => 0, 'time' => current_time( 'mysql' ), ); // Topics first, keeping any the site owner already has. $cats = Replyvane_Store::categories(); $have = wp_list_pluck( $cats, 'id' ); $order = count( $cats ); $used = array_unique( wp_list_pluck( $items, 'category' ) ); foreach ( array_merge( self::$topics, $extra_topics ) as $id => $title ) { if ( in_array( $id, $used, true ) && ! in_array( $id, $have, true ) ) { $cats[] = array( 'id' => $id, 'title' => $title, 'order' => ++$order, ); } } Replyvane_Store::save_categories( $cats ); $made = array(); foreach ( $items as $raw ) { $row = Replyvane_Store::clean_item( $raw ); if ( is_wp_error( $row ) ) { continue; } $row['source'] = 'auto'; $made[] = $row['slug']; $existing = Replyvane_Store::get_by_slug( $row['slug'] ); if ( $existing && 'auto' !== $existing['source'] ) { $report['kept']++; continue; } $result = Replyvane_Store::save( $row, $existing ? (int) $existing['id'] : 0, false ); if ( ! is_wp_error( $result ) ) { $report[ $existing ? 'updated' : 'added' ]++; } } // Remove generated questions the site no longer supports. foreach ( Replyvane_Store::items() as $row ) { if ( 'auto' === $row['source'] && ! in_array( $row['slug'], $made, true ) ) { Replyvane_Store::delete( $row['id'] ); $report['removed']++; } } Replyvane_Plugin::bump_version(); return $report; } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching