| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Content Clarity scorer. |
| 5 |
* |
| 6 |
* The class name "Readability" is kept for backward compatibility — externally |
| 7 |
* this is now "Content Clarity" everywhere user-facing. The score measures |
| 8 |
* human-skimmability rather than Flesch Reading Ease (which Google |
| 9 |
* de-emphasised). Per Google's May 2026 AI optimization guide, there is |
| 10 |
* explicitly no need to chunk content for AI — so we measure structure for |
| 11 |
* human readers, not for AI parsers. |
| 12 |
* |
| 13 |
* Three signals rolled up into a single 0-100 score: |
| 14 |
* - structure: 33 pts — presence and hierarchy of headings |
| 15 |
* - lists: 33 pts — bullet/numbered lists (genuinely help skim-reading) |
| 16 |
* - clarity: 34 pts — sentence-level readability |
| 17 |
* |
| 18 |
* Forgiving by design: a normal post with H2s and decent sentences scores 75+. |
| 19 |
* Low scores are reserved for genuinely problematic content. |
| 20 |
*/ |
| 21 |
class Meow_MWSEO_Modules_Readability |
| 22 |
{ |
| 23 |
/** |
| 24 |
* Detect if text is mostly CJK (Chinese, Japanese, Korean). |
| 25 |
* CJK content needs different thresholds — each character carries more meaning |
| 26 |
* than a Latin character, so paragraphs and sentences are naturally shorter. |
| 27 |
*/ |
| 28 |
public function is_mostly_cjk( $text, $threshold = 0.5 ) { |
| 29 |
if ( empty( $text ) ) return false; |
| 30 |
$len = mb_strlen( $text, 'UTF-8' ); |
| 31 |
if ( $len === 0 ) return false; |
| 32 |
preg_match_all( '/[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]/u', $text, $matches ); |
| 33 |
return ( count( $matches[0] ) / $len ) >= $threshold; |
| 34 |
} |
| 35 |
|
| 36 |
private function is_html_php( $string ) { |
| 37 |
return $string !== strip_tags( $string ); |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* Score a post's content for clarity and human-skimmability. |
| 42 |
* |
| 43 |
* @param string $content_html HTML content (post_content as stored). |
| 44 |
* @return array { score, breakdown, suggestions } |
| 45 |
* - score: int 0-100 |
| 46 |
* - breakdown: per-signal scores (structure 0-33, lists 0-33, clarity 0-34) |
| 47 |
* - suggestions: concrete, encouraging strings the agent / Magic Wand can act on |
| 48 |
*/ |
| 49 |
public function calculate_readability( $content_html ) { |
| 50 |
$content_html = (string) $content_html; |
| 51 |
|
| 52 |
if( ! $this->is_html_php( $content_html ) ) { |
| 53 |
return [ |
| 54 |
'score' => 0, |
| 55 |
'breakdown' => [ 'structure' => 0, 'lists' => 0, 'clarity' => 0 ], |
| 56 |
'suggestions' => [ 'Non-HTML content cannot be scored for readability properly.' ], |
| 57 |
]; |
| 58 |
} |
| 59 |
|
| 60 |
// Empty content: score 0, one suggestion. |
| 61 |
if ( trim( strip_tags( $content_html ) ) === '' ) { |
| 62 |
return [ |
| 63 |
'score' => 0, |
| 64 |
'breakdown' => [ 'structure' => 0, 'lists' => 0, 'clarity' => 0 ], |
| 65 |
'suggestions' => [ 'This post has no readable content yet.' ], |
| 66 |
]; |
| 67 |
} |
| 68 |
|
| 69 |
$headings = $this->extract_headings( $content_html ); |
| 70 |
$has_list = $this->has_list( $content_html ); |
| 71 |
$plain = $this->to_plain_text( $content_html ); |
| 72 |
$is_cjk = $this->is_mostly_cjk( $plain ); |
| 73 |
$word_count = $this->count_words( $plain, $is_cjk ); |
| 74 |
|
| 75 |
$struct = $this->score_structure( $headings, $word_count ); |
| 76 |
$lists = $this->score_lists( $has_list, $word_count ); |
| 77 |
$clarity = $this->score_clarity( $plain, $is_cjk ); |
| 78 |
|
| 79 |
$total = (int) round( $struct['score'] + $lists['score'] + $clarity['score'] ); |
| 80 |
// Clamp defensively. |
| 81 |
$total = max( 0, min( 100, $total ) ); |
| 82 |
|
| 83 |
$suggestions = array_merge( |
| 84 |
$struct['suggestions'], $lists['suggestions'], $clarity['suggestions'] |
| 85 |
); |
| 86 |
|
| 87 |
// If the post is in great shape, surface one encouraging line instead of an empty list. |
| 88 |
if ( empty( $suggestions ) && $total >= 80 ) { |
| 89 |
$suggestions[] = 'Your content is clear, well-structured, and easy to read.'; |
| 90 |
} |
| 91 |
|
| 92 |
return [ |
| 93 |
'score' => $total, |
| 94 |
'breakdown' => [ |
| 95 |
'structure' => (int) round( $struct['score'] ), |
| 96 |
'lists' => (int) round( $lists['score'] ), |
| 97 |
'clarity' => (int) round( $clarity['score'] ), |
| 98 |
], |
| 99 |
'suggestions' => $suggestions, |
| 100 |
]; |
| 101 |
} |
| 102 |
|
| 103 |
#region Signal scorers |
| 104 |
|
| 105 |
/** |
| 106 |
* Structure: does the post have headings, and are they organized? |
| 107 |
* - Any H2? base +16. |
| 108 |
* - Multiple H2s on posts >600 words? +8. |
| 109 |
* - Heading density ~ one per 300 words? +9. |
| 110 |
*/ |
| 111 |
private function score_structure( $headings, $word_count ) { |
| 112 |
$h2s = $headings['h2']; |
| 113 |
$all = $headings['count_all']; |
| 114 |
|
| 115 |
// Very short posts (< 200 words) don't need headings — give them a free pass. |
| 116 |
if ( $word_count > 0 && $word_count < 200 ) { |
| 117 |
return [ 'score' => 33, 'suggestions' => [] ]; |
| 118 |
} |
| 119 |
|
| 120 |
$score = 0; |
| 121 |
$suggestions = []; |
| 122 |
|
| 123 |
if ( $h2s >= 1 ) { |
| 124 |
$score += 16; |
| 125 |
if ( $word_count > 600 && $h2s >= 2 ) { |
| 126 |
$score += 8; |
| 127 |
} else if ( $word_count > 600 && $h2s === 1 ) { |
| 128 |
$suggestions[] = 'Your post is long enough to benefit from a second H2 section.'; |
| 129 |
} |
| 130 |
|
| 131 |
// Density: roughly one heading per 300 words is great. |
| 132 |
if ( $all > 0 && $word_count > 0 ) { |
| 133 |
$density = $word_count / $all; |
| 134 |
if ( $density >= 150 && $density <= 450 ) { |
| 135 |
$score += 9; |
| 136 |
} else if ( $density > 450 ) { |
| 137 |
$suggestions[] = 'Adding one or two more headings would make this easier to skim.'; |
| 138 |
} |
| 139 |
} |
| 140 |
} else { |
| 141 |
$suggestions[] = 'Add at least one H2 section heading. Readers rely on them to navigate long content.'; |
| 142 |
} |
| 143 |
|
| 144 |
return [ 'score' => min( 33, $score ), 'suggestions' => $suggestions ]; |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* Lists: bullet or numbered lists genuinely help skim-reading. |
| 149 |
* Short posts get a free pass — not everything needs a list. |
| 150 |
*/ |
| 151 |
private function score_lists( $has_list, $word_count ) { |
| 152 |
// Posts under 300 words: lists optional. |
| 153 |
if ( $word_count > 0 && $word_count < 300 ) { |
| 154 |
return [ 'score' => 33, 'suggestions' => [] ]; |
| 155 |
} |
| 156 |
if ( $has_list ) { |
| 157 |
return [ 'score' => 33, 'suggestions' => [] ]; |
| 158 |
} |
| 159 |
return [ |
| 160 |
'score' => 16, |
| 161 |
'suggestions' => [ 'A short bulleted summary of your key points would make this post much easier to skim.' ], |
| 162 |
]; |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* Clarity: how readable are the sentences? |
| 167 |
* - Latin: avg 14-22 words = ideal; 10-30 = fine. |
| 168 |
* - CJK: avg 25-60 chars = ideal. |
| 169 |
* - Bonus for share of short, readable sentences (< 28 words). |
| 170 |
*/ |
| 171 |
private function score_clarity( $plain, $is_cjk ) { |
| 172 |
$sentences = preg_split( '/(?<=[.!?。!?])\s+/u', $plain, -1, PREG_SPLIT_NO_EMPTY ); |
| 173 |
$sentences = array_filter( array_map( 'trim', $sentences ) ); |
| 174 |
if ( empty( $sentences ) ) { |
| 175 |
return [ 'score' => 0, 'suggestions' => [] ]; |
| 176 |
} |
| 177 |
|
| 178 |
if ( $is_cjk ) { |
| 179 |
$lens = array_map( function( $s ) { return mb_strlen( $s, 'UTF-8' ); }, $sentences ); |
| 180 |
$avg = array_sum( $lens ) / count( $lens ); |
| 181 |
if ( $avg >= 25 && $avg <= 60 ) return [ 'score' => 34, 'suggestions' => [] ]; |
| 182 |
if ( $avg > 60 ) { |
| 183 |
return [ 'score' => 18, 'suggestions' => [ 'Sentences are long. Try shorter, punchier statements where possible.' ] ]; |
| 184 |
} |
| 185 |
return [ 'score' => 26, 'suggestions' => [] ]; |
| 186 |
} |
| 187 |
|
| 188 |
$word_counts = array_map( function( $s ) { return str_word_count( $s ); }, $sentences ); |
| 189 |
$avg = array_sum( $word_counts ) / count( $word_counts ); |
| 190 |
$readable_ratio = count( array_filter( $word_counts, function( $n ) { return $n > 0 && $n < 28; } ) ) / count( $word_counts ); |
| 191 |
|
| 192 |
$score = 0; |
| 193 |
$suggestions = []; |
| 194 |
|
| 195 |
if ( $avg >= 14 && $avg <= 22 ) { |
| 196 |
$score = 27; |
| 197 |
} else if ( $avg >= 10 && $avg <= 28 ) { |
| 198 |
$score = 22; |
| 199 |
} else if ( $avg > 28 ) { |
| 200 |
$score = 11; |
| 201 |
$suggestions[] = sprintf( |
| 202 |
'Sentences average %d words. Splitting where you use "and" or commas makes them easier to read.', |
| 203 |
(int) round( $avg ) |
| 204 |
); |
| 205 |
} else { |
| 206 |
$score = 16; |
| 207 |
} |
| 208 |
|
| 209 |
// Readability bonus: up to +7 |
| 210 |
$score += round( $readable_ratio * 7 ); |
| 211 |
|
| 212 |
return [ 'score' => min( 34, $score ), 'suggestions' => $suggestions ]; |
| 213 |
} |
| 214 |
|
| 215 |
#endregion |
| 216 |
|
| 217 |
#region HTML / text helpers |
| 218 |
|
| 219 |
private function extract_headings( $html ) { |
| 220 |
preg_match_all( '/<(h[1-6])\b[^>]*>(.*?)<\/\1>/is', $html, $m ); |
| 221 |
$h2 = 0; $count_all = 0; |
| 222 |
foreach ( $m[1] ?? [] as $i => $tag ) { |
| 223 |
$tag = strtolower( $tag ); |
| 224 |
$text = trim( strip_tags( $m[2][ $i ] ?? '' ) ); |
| 225 |
if ( $text === '' ) continue; |
| 226 |
$count_all++; |
| 227 |
if ( $tag === 'h2' ) $h2++; |
| 228 |
} |
| 229 |
return [ 'h2' => $h2, 'count_all' => $count_all ]; |
| 230 |
} |
| 231 |
|
| 232 |
private function has_list( $html ) { |
| 233 |
return (bool) preg_match( '/<(ul|ol)\b[^>]*>.*?<li\b[^>]*>.+?<\/li>.*?<\/\1>/is', $html ); |
| 234 |
} |
| 235 |
|
| 236 |
private function to_plain_text( $html ) { |
| 237 |
$s = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $html ); |
| 238 |
$s = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', '', $s ); |
| 239 |
$s = strip_tags( $s ); |
| 240 |
$s = preg_replace( '/\[.*?\]/', '', $s ); // strip shortcodes |
| 241 |
$s = preg_replace( '/\s+/u', ' ', $s ); |
| 242 |
return trim( $s ); |
| 243 |
} |
| 244 |
|
| 245 |
private function count_words( $plain, $is_cjk ) { |
| 246 |
if ( $is_cjk ) { |
| 247 |
// Each CJK character ≈ one "word-unit". Generous rounding. |
| 248 |
return (int) max( 1, floor( mb_strlen( $plain, 'UTF-8' ) * 0.6 ) ); |
| 249 |
} |
| 250 |
return str_word_count( $plain ); |
| 251 |
} |
| 252 |
|
| 253 |
#endregion |
| 254 |
} |
| 255 |
|