array( 'post', 'page' ), 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => $limit, 'orderby' => 'modified', 'order' => 'DESC', ) ); $rows = array(); foreach ( $ids as $id ) { $post = get_post( $id ); if ( ! $post instanceof WP_Post ) { continue; } $s = $this->score_post( $post ); $rows[] = array( 'id' => $id, 'title' => evoka_plain_text( get_the_title( $post ) ), 'edit_link' => get_edit_post_link( $id, 'raw' ), 'score' => $s['score'], 'factors' => $s['factors'], 'top_fix' => $s['top_fix'], ); } usort( $rows, function ( $a, $b ) { return $a['score'] - $b['score']; } ); return $rows; } /** * Score one post. Each factor contributes a weight to a 0-100 total; the * lowest-scoring factor becomes the suggested fix. * * @param WP_Post $post Post. * @return array {score:int, factors:array, top_fix:string} */ public function score_post( $post ) { $content = (string) $post->post_content; $plain = evoka_plain_text( $content ); $words = preg_split( '/\s+/u', trim( $plain ), -1, PREG_SPLIT_NO_EMPTY ); $word_count = count( $words ); $factors = array(); // 1. Answer-first opening paragraph: short, declarative, near the top. $first_p = ''; if ( preg_match( '#]*>(.*?)

#is', $content, $m ) ) { $first_p = evoka_plain_text( $m[1] ); } $opening_words = '' !== $first_p ? count( preg_split( '/\s+/u', $first_p, -1, PREG_SPLIT_NO_EMPTY ) ) : 0; if ( $opening_words >= 15 && $opening_words <= 60 ) { $factors['opening'] = array( 'weight' => 25, 'status' => 'pass', 'fix' => __( 'Opening paragraph is a good length for AI engines to quote directly.', 'evoka' ), ); } elseif ( 0 === $opening_words ) { $factors['opening'] = array( 'weight' => 25, 'status' => 'fail', 'fix' => __( 'Add a short opening paragraph that directly answers what the page is about - AI engines quote the first clear answer they find.', 'evoka' ), ); } else { $factors['opening'] = array( 'weight' => 25, 'status' => 'warn', 'fix' => $opening_words > 60 ? __( 'The opening paragraph is long. Lead with one short, direct sentence answering the main question before the detail.', 'evoka' ) : __( 'The opening paragraph is very short. Expand it to a full, self-contained answer (about 20-40 words).', 'evoka' ), ); } // 2. A number/statistic near the top - AI engines preferentially quote specifics. $has_number = (bool) preg_match( '/\d/', $first_p ); $factors['stat'] = array( 'weight' => 10, 'status' => $has_number ? 'pass' : 'warn', 'fix' => __( 'Include a specific number, date or statistic in the opening paragraph - AI engines cite concrete facts more readily than general statements.', 'evoka' ), ); // 3. Question-style headings. $heading_count = preg_match_all( '/]*>(.*?)<\/h[23]>/is', $content, $hm ); $question_headings = 0; if ( $heading_count ) { foreach ( $hm[1] as $h ) { $text = evoka_plain_text( $h ); if ( '' !== $text && ( '?' === substr( $text, -1 ) || preg_match( '/^(how|what|why|when|where|who|which|can|do|does|is|are|should)\b/i', $text ) ) ) { ++$question_headings; } } } if ( $question_headings > 0 ) { $factors['headings'] = array( 'weight' => 20, 'status' => 'pass', 'fix' => __( 'Has question-style headings - the format AI engines extract most readily.', 'evoka' ), ); } elseif ( $heading_count > 0 ) { $factors['headings'] = array( 'weight' => 20, 'status' => 'warn', 'fix' => __( 'Rephrase one or two headings as questions (e.g. "What is...", "How to...") so AI engines can match them to what people ask.', 'evoka' ), ); } else { $factors['headings'] = array( 'weight' => 20, 'status' => 'fail', 'fix' => __( 'Add subheadings, ideally phrased as questions, so AI engines can extract individual answers from the page.', 'evoka' ), ); } // 4. FAQ/HowTo structured data (from the Pro bulk-fixer). $enh = get_post_meta( $post->ID, Evoka_Bulk_Fixer::META_KEY, true ); $has_faq = ! empty( $enh['faq'] ); $has_howto = ! empty( $enh['howto']['steps'] ); $factors['structured_data'] = array( 'weight' => 15, 'status' => ( $has_faq || $has_howto ) ? 'pass' : 'warn', 'fix' => __( 'Run the bulk fix (Evoka -> Dashboard -> Tools) so question-style sections on this page get FAQ structured data.', 'evoka' ), ); // 5. Adequate depth. if ( $word_count >= 300 ) { $factors['depth'] = array( 'weight' => 15, 'status' => 'pass', 'fix' => __( 'Good depth for AI engines to draw a complete answer from.', 'evoka' ), ); } elseif ( $word_count >= 100 ) { $factors['depth'] = array( 'weight' => 15, 'status' => 'warn', 'fix' => __( 'This page is fairly short. Add more detail and examples so it is a complete answer on its own.', 'evoka' ), ); } else { $factors['depth'] = array( 'weight' => 15, 'status' => 'fail', 'fix' => __( 'This page is too thin to cite from. Expand it to at least a few hundred words.', 'evoka' ), ); } // 6. Freshness. $age_days = ( time() - (int) get_post_modified_time( 'U', true, $post ) ) / DAY_IN_SECONDS; $factors['freshness'] = array( 'weight' => 15, 'status' => $age_days <= 180 ? 'pass' : ( $age_days <= 365 ? 'warn' : 'fail' ), 'fix' => __( 'This page hasn\'t been updated in a while. Refresh the content and dates - AI engines prefer current sources.', 'evoka' ), ); $earned = 0; $total = 0; $worst = null; foreach ( $factors as $f ) { $total += $f['weight']; $ratio = ( 'pass' === $f['status'] ) ? 1 : ( ( 'warn' === $f['status'] ) ? 0.5 : 0 ); $earned += $f['weight'] * $ratio; if ( 'pass' !== $f['status'] && ( null === $worst || $f['weight'] > $worst['weight'] ) ) { $worst = $f; } } return array( 'score' => $total > 0 ? (int) round( ( $earned / $total ) * 100 ) : 0, 'factors' => $factors, 'top_fix' => $worst ? $worst['fix'] : __( 'No major issues - this page is well set up for AI citation.', 'evoka' ), ); } }