post_content; $rendered = do_shortcode( do_blocks( $raw_content ) ); $plain_text = wp_strip_all_tags( $rendered ); $plain_text = preg_replace( '/\s+/', ' ', trim( $plain_text ) ); // ── Basic page data ── $basic = $this->extract_basic( $post, $plain_text ); // ── Content analysis: headings, images, videos ── $analyzer = new Schema_AI_Content_Analyzer(); $headings = $analyzer->extract_headings( $raw_content ); $images = $analyzer->get_images( $post_id ); $videos = $analyzer->get_videos( $post_id ); // ── Contact / location from page content ── $field_extractor = new Schema_AI_Field_Extractor(); $contact = $this->extract_contact( $field_extractor, $post_id, $plain_text ); // ── Business info from site-wide settings ── $business = $this->extract_business_settings( $settings ); // ── Media ── $media = $this->extract_media( $post_id, $images, $videos ); // ── FAQ detection (heading-based + accordion) ── $faq = $this->extract_faq( $post_id, $rendered, $headings ); // ── HowTo step detection ── $howto = $this->extract_howto( $rendered ); // ── Product data (WooCommerce) ── $product = $this->extract_product( $post_id ); // ── Reviews / ratings ── $reviews = $this->extract_reviews( $field_extractor, $post_id ); // ── Content signals for classifier ── $content_signals = array( 'has_faq' => ! empty( $faq ), 'faq_count' => count( $faq ), 'has_howto' => ! empty( $howto['steps'] ), 'has_product' => ! empty( $product ), 'has_video' => ! empty( $videos ), 'has_reviews' => ! empty( $reviews['aggregate_rating'] ), 'has_phone' => ! empty( $contact['phone'] ) || ! empty( $business['phone'] ), 'has_address' => ! empty( $contact['address'] ) || ! empty( $business['address'] ), 'has_hours' => ! empty( $business['hours'] ), 'heading_count' => count( $headings ), 'headings_list' => array_map( function ( $h ) { return $h['text']; }, array_slice( $headings, 0, 15 ) ), ); return array( 'basic' => $basic, 'contact' => $contact, 'business' => $business, 'media' => $media, 'faq' => $faq, 'howto' => $howto, 'product' => $product, 'reviews' => $reviews, 'content_signals' => $content_signals, ); } /** * Build a lightweight summary for the classify endpoint. * * Keeps token count low (~200-300 tokens) while giving the AI enough * context to classify the page type. * * @param array $extracted Full extraction result from extract(). * @return array Summary suitable for classification. */ public function build_classify_summary( $extracted ) { $basic = isset( $extracted['basic'] ) ? $extracted['basic'] : array(); $signals = isset( $extracted['content_signals'] ) ? $extracted['content_signals'] : array(); // First 600 chars of plain text — enough for the AI to detect industry/topic // without sending the full page content. $plain = isset( $basic['plain_text'] ) ? $basic['plain_text'] : ''; $snippet = mb_substr( wp_strip_all_tags( $plain ), 0, 600 ); return array( 'title' => isset( $basic['title'] ) ? $basic['title'] : '', 'post_type' => isset( $basic['post_type'] ) ? $basic['post_type'] : 'post', 'word_count' => isset( $basic['word_count'] ) ? $basic['word_count'] : 0, 'snippet' => $snippet, 'has_faq' => ! empty( $signals['has_faq'] ), 'faq_count' => isset( $signals['faq_count'] ) ? $signals['faq_count'] : 0, 'has_steps' => ! empty( $signals['has_howto'] ), 'has_product' => ! empty( $signals['has_product'] ), 'has_reviews' => ! empty( $signals['has_reviews'] ), 'has_phone' => ! empty( $signals['has_phone'] ), 'has_address' => ! empty( $signals['has_address'] ), 'has_hours' => ! empty( $signals['has_hours'] ), 'has_video' => ! empty( $signals['has_video'] ), 'headings' => isset( $signals['headings_list'] ) ? $signals['headings_list'] : array(), 'categories' => isset( $basic['categories'] ) ? $basic['categories'] : array(), ); } /** * Extract basic post data. * * @param WP_Post $post The post object. * @param string $plain_text Stripped content. * @return array Basic data. */ private function extract_basic( $post, $plain_text ) { $author = get_userdata( $post->post_author ); $categories = get_the_terms( $post->ID, 'category' ); $cat_names = ( $categories && ! is_wp_error( $categories ) ) ? wp_list_pluck( $categories, 'name' ) : array(); return array( 'title' => $post->post_title, 'excerpt' => has_excerpt( $post->ID ) ? get_the_excerpt( $post->ID ) : wp_trim_words( $plain_text, 55, '...' ), 'url' => get_permalink( $post->ID ), 'post_type' => $post->post_type, 'word_count' => str_word_count( $plain_text ), 'author' => $author ? $author->display_name : '', 'date' => get_the_date( 'c', $post->ID ), 'modified' => get_the_modified_date( 'c', $post->ID ), 'categories' => $cat_names, ); } /** * Extract contact info from page content. * * @param Schema_AI_Field_Extractor $extractor Field extractor instance. * @param int $post_id Post ID. * @param string $plain_text Stripped content. * @return array Contact data. */ private function extract_contact( $extractor, $post_id, $plain_text ) { $data = array(); $phone = $extractor->find_phone_number( $plain_text ); if ( $phone ) { $data['phone'] = $extractor->format_phone( $phone ); } $email = $extractor->find_email( $plain_text ); if ( $email ) { $data['email'] = sanitize_email( $email ); } $address = $extractor->find_address( $plain_text ); if ( $address ) { $data['address'] = $address; } return $data; } /** * Extract business info from plugin settings. * * @param array $settings Schema AI settings. * @return array Business data. */ private function extract_business_settings( $settings ) { $data = array(); $data['org_name'] = ! empty( $settings['default_org_name'] ) ? $settings['default_org_name'] : get_bloginfo( 'name' ); $data['url'] = ! empty( $settings['default_org_url'] ) ? $settings['default_org_url'] : home_url( '/' ); // Logo. $logo = ''; if ( ! empty( $settings['default_org_logo'] ) ) { $logo = $settings['default_org_logo']; } else { $custom_logo_id = get_theme_mod( 'custom_logo' ); if ( $custom_logo_id ) { $logo_src = wp_get_attachment_image_src( $custom_logo_id, 'full' ); if ( $logo_src ) { $logo = $logo_src[0]; } } } if ( ! empty( $logo ) ) { $data['logo'] = $logo; } // Phone. if ( ! empty( $settings['default_org_phone'] ) ) { $data['phone'] = $settings['default_org_phone']; } // Contact email. if ( ! empty( $settings['default_org_email'] ) ) { $data['email'] = $settings['default_org_email']; } // Address. if ( ! empty( $settings['default_org_address'] ) ) { $data['address'] = array( 'streetAddress' => $settings['default_org_address'], 'addressLocality' => isset( $settings['default_org_city'] ) ? $settings['default_org_city'] : '', 'addressRegion' => isset( $settings['default_org_state'] ) ? $settings['default_org_state'] : '', 'postalCode' => isset( $settings['default_org_zip'] ) ? $settings['default_org_zip'] : '', 'addressCountry' => isset( $settings['default_org_country'] ) ? $settings['default_org_country'] : '', ); // Remove empty values. $data['address'] = array_filter( $data['address'] ); } // Business hours. if ( ! empty( $settings['default_org_hours'] ) ) { $data['hours'] = $settings['default_org_hours']; } // Service areas. if ( ! empty( $settings['default_service_areas'] ) ) { $data['service_areas'] = $settings['default_service_areas']; } return $data; } /** * Extract media data. * * @param int $post_id Post ID. * @param array $images Images from content analyzer. * @param array $videos Videos from content analyzer. * @return array Media data. */ private function extract_media( $post_id, $images, $videos ) { $data = array(); // Featured image. $thumbnail_id = get_post_thumbnail_id( $post_id ); if ( $thumbnail_id ) { $src = wp_get_attachment_image_src( $thumbnail_id, 'full' ); if ( $src ) { $data['featured_image'] = array( 'url' => $src[0], 'width' => $src[1], 'height' => $src[2], ); } } if ( ! empty( $images ) ) { $data['gallery_images'] = $images; } if ( ! empty( $videos ) ) { $data['videos'] = $videos; } return $data; } /** * Extract FAQ pairs from headings and accordion patterns. * * @param int $post_id Post ID. * @param string $rendered Rendered HTML content. * @param array $headings Extracted headings. * @return array Array of [ 'question' => string, 'answer' => string ]. */ private function extract_faq( $post_id, $rendered, $headings ) { $pairs = array(); // Heading-based: works for any HTML structure — grabs plain text between // the question heading and the next heading, so it handles Rank Math blocks, // plain paragraphs, bold-question formats, and accordion markup without // needing plugin-specific CSS class selectors. $pairs = array_merge( $pairs, $this->detect_faq_from_headings( $rendered ) ); // Accordion-based detection for explicit plugin-managed FAQ widgets. $accordion_detector = new Schema_AI_Accordion_Detector(); $pairs = array_merge( $pairs, $accordion_detector->detect( $post_id ) ); // Deduplicate by question text. $seen = array(); $unique = array(); foreach ( $pairs as $pair ) { $key = strtolower( trim( $pair['question'] ) ); if ( ! isset( $seen[ $key ] ) ) { $seen[ $key ] = true; $unique[] = $pair; } } return $unique; } /** * Detect FAQ from heading patterns (questions followed by any content). * * @param string $html Rendered HTML. * @return array FAQ pairs. */ private function detect_faq_from_headings( $html ) { $pairs = array(); $pattern = '/]*>(.*?)<\/h\1>/is'; if ( ! preg_match_all( $pattern, $html, $matches, PREG_OFFSET_CAPTURE ) ) { return $pairs; } $total = count( $matches[0] ); for ( $i = 0; $i < $total; $i++ ) { $heading_text = wp_strip_all_tags( $matches[2][ $i ][0] ); $heading_text = trim( $heading_text ); if ( ! $this->is_question( $heading_text ) ) { continue; } // Get content between this heading and the next. // Cap at 2000 chars to avoid grabbing sidebar/footer content when // a question heading appears near the bottom of the article. $start = $matches[0][ $i ][1] + strlen( $matches[0][ $i ][0] ); $end = ( $i + 1 < $total ) ? $matches[0][ $i + 1 ][1] : strlen( $html ); $between = substr( $html, $start, min( $end - $start, 2000 ) ); // Strip all HTML tags and collapse whitespace. // Using plain-text extraction (rather than only

tags) makes this // work with any HTML structure: Rank Math

, // plain

paragraphs, accordion wrappers, etc. $answer = trim( wp_strip_all_tags( $between ) ); $answer = preg_replace( '/\s+/', ' ', $answer ); if ( ! empty( $answer ) ) { $pairs[] = array( 'question' => $heading_text, 'answer' => $answer, ); } } return $pairs; } /** * Check if text looks like a question. * * @param string $text Heading text. * @return bool */ private function is_question( $text ) { // Only a literal "?" reliably indicates a question heading. // Question-word heuristics ("How", "What", "Why"...) produce too many // false positives on common section headings like "How We Work", // "What to Look for Instead", "Why Choose Us", etc. return false !== strpos( $text, '?' ); } /** * Extract HowTo steps from content. * * @param string $html Rendered HTML. * @return array HowTo data with 'steps' and optional 'total_time'. */ private function extract_howto( $html ) { $data = array( 'steps' => array() ); $steps = array(); // Detect ordered list steps. if ( preg_match_all( '/]*>(.*?)<\/ol>/is', $html, $ol_matches ) ) { foreach ( $ol_matches[1] as $ol_content ) { if ( preg_match_all( '/]*>(.*?)<\/li>/is', $ol_content, $li_matches ) ) { $list_steps = array(); foreach ( $li_matches[1] as $step_html ) { $step_text = trim( wp_strip_all_tags( $step_html ) ); if ( ! empty( $step_text ) ) { $step = array( 'text' => $step_text ); if ( preg_match( '/]+src=["\']([^"\']+)["\']/', $step_html, $img ) ) { $step['image'] = $img[1]; } $list_steps[] = $step; } } if ( empty( $list_steps ) ) { continue; } // Skip this list if more than half the items are questions. // Lists of questions ("What to ask your contractor?") are FAQ // or tip lists — not procedural HowTo steps. $question_count = 0; foreach ( $list_steps as $s ) { if ( $this->is_question( $s['text'] ) ) { $question_count++; } } if ( $question_count / count( $list_steps ) > 0.5 ) { continue; } $steps = array_merge( $steps, $list_steps ); } } } // Fallback: numbered headings (Step 1:, Step 2:, etc.). if ( empty( $steps ) ) { $pattern = '/]*>\s*(?:step\s*\d+\s*[:\-.]?\s*)(.*?)<\/h\1>/is'; if ( preg_match_all( $pattern, $html, $matches, PREG_OFFSET_CAPTURE ) ) { $total = count( $matches[0] ); for ( $i = 0; $i < $total; $i++ ) { $name = trim( wp_strip_all_tags( $matches[2][ $i ][0] ) ); $start = $matches[0][ $i ][1] + strlen( $matches[0][ $i ][0] ); $end = ( $i + 1 < $total ) ? $matches[0][ $i + 1 ][1] : strlen( $html ); $between = substr( $html, $start, $end - $start ); $text = ''; if ( preg_match_all( '/]*>(.*?)<\/p>/is', $between, $p_matches ) ) { $paragraphs = array(); foreach ( $p_matches[1] as $p ) { $clean = trim( wp_strip_all_tags( $p ) ); if ( ! empty( $clean ) ) { $paragraphs[] = $clean; } } $text = implode( ' ', $paragraphs ); } if ( ! empty( $name ) && ! empty( $text ) ) { $steps[] = array( 'name' => $name, 'text' => $text, ); } } } } $data['steps'] = $steps; return $data; } /** * Extract WooCommerce product data. * * @param int $post_id Post ID. * @return array Product data or empty array. */ private function extract_product( $post_id ) { if ( ! function_exists( 'wc_get_product' ) ) { return array(); } $product = wc_get_product( $post_id ); if ( ! $product ) { return array(); } $data = array( 'price' => $product->get_price(), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD', 'availability' => $product->is_in_stock() ? 'InStock' : 'OutOfStock', ); $sku = $product->get_sku(); if ( ! empty( $sku ) ) { $data['sku'] = $sku; } // Brand. if ( class_exists( 'Schema_AI_WooCommerce' ) ) { $woo = new Schema_AI_WooCommerce(); $brand = $woo->get_product_brand( $product ); if ( ! empty( $brand ) ) { $data['brand'] = $brand; } } return $data; } /** * Extract review / rating data. * * @param Schema_AI_Field_Extractor $extractor Field extractor. * @param int $post_id Post ID. * @return array Review data. */ private function extract_reviews( $extractor, $post_id ) { $data = array(); // WooCommerce ratings. if ( function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $post_id ); if ( $product ) { $avg = $product->get_average_rating(); $count = $product->get_review_count(); if ( $avg > 0 && $count > 0 ) { $data['aggregate_rating'] = array( 'rating_value' => floatval( $avg ), 'review_count' => intval( $count ), ); return $data; } } } // WordPress comment-based ratings. $comments = get_comments( array( 'post_id' => $post_id, 'status' => 'approve', 'type' => 'comment', 'number' => 100, ) ); if ( ! empty( $comments ) ) { $ratings = array(); foreach ( $comments as $comment ) { $rating = get_comment_meta( $comment->comment_ID, 'rating', true ); if ( $rating && is_numeric( $rating ) ) { $ratings[] = floatval( $rating ); } } if ( ! empty( $ratings ) ) { $data['aggregate_rating'] = array( 'rating_value' => round( array_sum( $ratings ) / count( $ratings ), 1 ), 'review_count' => count( $ratings ), ); } } return $data; } }