$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 );
}
}