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' ),
);
}
}