PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / modules / readability.php

readability.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/modules/readability.php

255 lines 8.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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