$post_types, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => 1, 'no_found_rows' => false, ) ); return (int) $q->found_posts; } /** * Process one batch starting at an offset. * * @param int $offset Offset into the result set. * @param array $post_types Post types to include. * @return array {processed:int, enhanced:int, done:bool, offset:int} */ public function process_batch( $offset = 0, $post_types = array( 'post', 'page' ) ) { $ids = get_posts( array( 'post_type' => $post_types, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => self::BATCH_SIZE, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', ) ); $enhanced = 0; foreach ( $ids as $id ) { if ( $this->enhance_post( (int) $id ) ) { ++$enhanced; } } $next = $offset + count( $ids ); $done = ( count( $ids ) < self::BATCH_SIZE ); return array( 'processed' => count( $ids ), 'enhanced' => $enhanced, 'offset' => $next, 'done' => $done, ); } /** * Compute and store enhancements for a single post. * * @param int $post_id Post ID. * @return bool True if any enhancement was recorded. */ public function enhance_post( $post_id ) { $post = get_post( $post_id ); if ( ! $post instanceof WP_Post ) { return false; } $enh = array(); // 1. Detect FAQ pairs from headings that look like questions. $faqs = $this->extract_faqs( $post->post_content ); if ( ! empty( $faqs ) ) { $enh['faq'] = $faqs; } // 2. HowTo only for clearly instructional content with real steps. $steps = $this->extract_howto_steps( $post ); if ( ! empty( $steps ) ) { $enh['howto'] = array( 'steps' => $steps ); } if ( empty( $enh ) ) { // Nothing to add; make sure no stale meta lingers. delete_post_meta( $post_id, self::META_KEY ); return false; } update_post_meta( $post_id, self::META_KEY, $enh ); return true; } /** * Extract question/answer pairs from a post's HTML. * * Heuristic: an H2/H3 phrased as a question, followed by the next * paragraph as the answer. * * @param string $content Post content (HTML). * @return array List of {question, answer}. */ public function extract_faqs( $content ) { $faqs = array(); if ( '' === trim( (string) $content ) ) { return $faqs; } if ( ! preg_match_all( '#]*>(.*?)\s*(.*?)(?=]*>|$)#is', $content, $matches, PREG_SET_ORDER ) ) { return $faqs; } foreach ( $matches as $m ) { $question = evoka_plain_text( $m[1] ); if ( '' === $question || ! $this->is_question( $question ) ) { continue; } // First paragraph of the following block is the answer. $answer = ''; if ( preg_match( '#]*>(.*?)

#is', $m[2], $pm ) ) { $answer = evoka_plain_text( $pm[1] ); } else { $answer = evoka_plain_text( $m[2] ); } $answer = $this->shorten( $answer, 60 ); if ( '' === $answer ) { continue; } $faqs[] = array( 'question' => $question, 'answer' => $answer, ); if ( count( $faqs ) >= 10 ) { break; } } return $faqs; } /** * Does a heading read like a question? * * @param string $text Heading text. * @return bool */ private function is_question( $text ) { if ( '?' === substr( trim( $text ), -1 ) ) { return true; } $starts = array( 'how', 'what', 'why', 'when', 'where', 'who', 'which', 'can', 'do', 'does', 'is', 'are', 'should' ); $first = strtolower( strtok( $text, " \t" ) ); return in_array( $first, $starts, true ); } /** * Extract HowTo steps from clearly instructional content. * * A post qualifies only if its title contains "how to" or it has "Step N" * headings - a numbered list on its own (e.g. a top-10 list) is not a * how-to. Steps come from the "Step N" headings (name + following * paragraph) or, failing that, the items of the first ordered list. * At least two steps are required. * * @param WP_Post $post Post. * @return array List of {name?, text}, or empty if not a how-to. */ public function extract_howto_steps( $post ) { $content = (string) $post->post_content; $howto_title = (bool) preg_match( '/\bhow\s+to\b/i', evoka_plain_text( $post->post_title ) ); $step_headings = preg_match_all( '#]*>\s*(step\s*\d+.*?)\s*(.*?)(?=]*>|$)#is', $content, $hm, PREG_SET_ORDER ); if ( ! $howto_title && ! $step_headings ) { return array(); } $steps = array(); if ( $step_headings ) { foreach ( $hm as $m ) { $name = evoka_plain_text( $m[1] ); $text = preg_match( '#]*>(.*?)

#is', $m[2], $pm ) ? evoka_plain_text( $pm[1] ) : evoka_plain_text( $m[2] ); $steps[] = array( 'name' => $name, 'text' => $this->shorten( '' !== $text ? $text : $name, 60 ), ); } } elseif ( preg_match( '#]*>(.*?)#is', $content, $om ) && preg_match_all( '#]*>(.*?)#is', $om[1], $lm ) ) { foreach ( $lm[1] as $li ) { $text = $this->shorten( evoka_plain_text( $li ), 60 ); if ( '' !== $text ) { $steps[] = array( 'text' => $text ); } } } $steps = array_slice( $steps, 0, 20 ); return count( $steps ) >= 2 ? $steps : array(); } /** * Trim text to a word count. * * @param string $text Text. * @param int $words Max words. * @return string */ private function shorten( $text, $words ) { return evoka_trim_words( $text, $words ); } /** * Remove all enhancements (revert). * * @return int Number of posts cleared. */ public function revert_all() { $ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => -1, 'meta_key' => self::META_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key ) ); foreach ( $ids as $id ) { delete_post_meta( (int) $id, self::META_KEY ); } return count( $ids ); } }