PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/api/class-seo-score-endpoint.php +127 -13 1.0.02.9.0 View file →
@@ -80,8 +80,19 @@
80 80 'required' => false,
81 81 'type' => 'string',
82 82 'sanitize_callback' => 'sanitize_text_field',
83 83 ],
84 + 'target_keywords' => [
85 + 'required' => false,
86 + 'type' => 'array',
87 + 'items' => ['type' => 'string'],
88 + 'sanitize_callback' => function ($value) {
89 + if (!is_array($value)) {
90 + return [];
91 + }
92 + return array_map('sanitize_text_field', $value);
93 + },
94 + ],
84 95 'save_score' => [
85 96 'required' => false,
86 97 'type' => 'boolean',
87 98 'default' => true,
@@ -90,8 +101,51 @@
90 101 'required' => false,
91 102 'type' => 'string',
92 103 'sanitize_callback' => 'wp_kses_post',
93 104 ],
105 + // Unsaved SEO title/description straight from the editor. Both
106 + // are scored factors, and without them an "Apply" that changes
107 + // the title scores against the stale saved value — the score
108 + // only moved once the post was saved, which read as the score
109 + // updating minutes later on its own. Omitted (null) means "use
110 + // what's saved"; an empty string means the user cleared the
111 + // field, which must fall back to the rendered pattern exactly
112 + // as an empty stored value does.
113 + 'live_title' => [
114 + 'required' => false,
115 + 'type' => 'string',
116 + 'sanitize_callback' => 'sanitize_text_field',
117 + ],
118 + 'live_description' => [
119 + 'required' => false,
120 + 'type' => 'string',
121 + 'sanitize_callback' => 'sanitize_text_field',
122 + ],
123 + // The permalink field as it stands in the editor, so an unsaved
124 + // slug edit scores immediately instead of matching the old URL.
125 + 'live_slug' => [
126 + 'required' => false,
127 + 'type' => 'string',
128 + // NOT sanitize_title: REST calls a sanitize_callback as
129 + // ($value, $request, $param), and sanitize_title()'s second
130 + // parameter is $fallback_title — so an empty slug returned
131 + // the WP_REST_Request object, which fataled on the string
132 + // cast below. The editor sends an empty slug whenever the
133 + // permalink field is blank (every draft), so this fired on
134 + // ordinary use. Sanitized with a single-argument
135 + // sanitize_title() where it is consumed instead.
136 + 'sanitize_callback' => 'sanitize_text_field',
137 + ],
138 + 'readability_score' => [
139 + 'required' => false,
140 + 'type' => 'string',
141 + 'sanitize_callback' => 'sanitize_text_field',
142 + ],
143 + 'content_quality' => [
144 + 'required' => false,
145 + 'type' => 'string',
146 + 'sanitize_callback' => 'sanitize_text_field',
147 + ],
94 148 ],
95 149 ]);
96 150
97 151 // Get existing SEO score endpoint
@@ -145,9 +199,9 @@
145 199 }
146 200
147 201 /**
148 202 * Calculate SEO score for a post
149 - *
203 + *
150 204 * @param WP_REST_Request $request Request object
151 205 * @return WP_REST_Response|WP_Error Response object
152 206 */
153 207 public function calculate_score(WP_REST_Request $request) {
@@ -153,10 +207,13 @@
153 207 public function calculate_score(WP_REST_Request $request) {
154 208 try {
155 209 $post_id = $request->get_param('post_id');
156 210 $target_keyword = $request->get_param('target_keyword') ?? '';
211 + $target_keywords = $request->get_param('target_keywords');
157 212 $save_score = $request->get_param('save_score') ?? true;
158 213 $live_content = $request->get_param('live_content') ?? '';
214 + $readability_score = $request->get_param('readability_score') ?? null;
215 + $content_quality = $request->get_param('content_quality') ?? null;
159 216
160 217 // Analyze content - use live content if provided, otherwise saved content
161 218 if (!empty($live_content)) {
162 219 $content_data = $this->calculator->analyze_live_content($live_content, $post_id);
@@ -162,9 +219,9 @@
162 219 $content_data = $this->calculator->analyze_live_content($live_content, $post_id);
163 220 } else {
164 221 $content_data = $this->calculator->analyze_post_content($post_id);
165 222 }
166 -
223 +
167 224 if (empty($content_data)) {
168 225 return new WP_Error(
169 226 'post_not_found',
170 227 'Post not found or has no content',
@@ -170,37 +227,94 @@
170 227 'Post not found or has no content',
171 228 ['status' => 404]
172 229 );
173 230 }
174 -
175 - // Get post metadata
231 +
232 + // Get post metadata. Score against the FINAL rendered SEO title/
233 + // description — resolve any variable tags in a custom value, and fall
234 + // back to the rendered Global/Bulk pattern when the field is empty, so
235 + // the length-based scoring matches what the frontend actually outputs.
236 + // Prefer the editor's live values when the request carried them, so
237 + // an unsaved title/description edit scores immediately instead of
238 + // waiting for the post to be saved. `null` means the request said
239 + // nothing about the field, which keeps the saved value.
240 + $live_title = $request->get_param('live_title');
241 + $live_description = $request->get_param('live_description');
242 +
243 + // Score the slug the editor is showing. Omitted (null) keeps the
244 + // saved slug; an empty string means the field was cleared, which
245 + // falls back to the title-derived slug exactly as an unsaved draft
246 + // does inside the calculator.
247 + $live_slug = $request->get_param('live_slug');
248 + if ($live_slug !== null) {
249 + $content_data['slug'] = sanitize_title((string) $live_slug);
250 + }
251 +
252 + $raw_title = $live_title !== null
253 + ? (string) $live_title
254 + : get_post_meta($post_id, '_thinkrank_seo_title', true);
255 + $raw_description = $live_description !== null
256 + ? (string) $live_description
257 + : get_post_meta($post_id, '_thinkrank_meta_description', true);
176 258 $metadata = [
177 - 'title' => get_post_meta($post_id, '_thinkrank_seo_title', true) ?: $content_data['title'],
178 - 'description' => get_post_meta($post_id, '_thinkrank_meta_description', true) ?: '',
259 + 'title' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_title, $post_id, 'title'),
260 + 'description' => \ThinkRank\SEO\Pattern_Resolver::effective_value($raw_description, $post_id, 'description'),
261 + // Fallback source for the scorer when the request carries no
262 + // keyword (e.g. a plain "score this post" call). An explicit
263 + // request keyword still wins, so the editor keeps scoring
264 + // unsaved keyword edits live.
265 + 'focus_keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
179 266 ];
180 -
181 - // Calculate score
267 +
268 + // Calculate score. Prefer the multi-keyword list when provided; the
269 + // calculator scores each keyword and returns the highest as final.
270 + //
271 + // Only forward keyword options when the request actually carried
272 + // them. The editor always sends its live keyword state (including
273 + // empty, when the user clears the field) and that must be honored
274 + // verbatim; a request that mentions no keyword at all leaves the
275 + // options untouched so the calculator falls back to the keywords
276 + // stored on the post.
277 + $score_options = [];
278 + if ($request->get_param('target_keyword') !== null) {
279 + $score_options['target_keyword'] = (string) $target_keyword;
280 + }
281 + if (is_array($target_keywords)) {
282 + $score_options['target_keywords'] = $target_keywords;
283 + }
182 284 $score_data = $this->calculator->calculate_score(
183 285 $content_data,
184 286 $metadata,
185 - ['target_keyword' => $target_keyword]
287 + $score_options
186 288 );
187 -
289 +
290 + // Add readability_score and content_quality from frontend if
291 + // provided. `!== null`, not `!empty()`: 0 is a legitimate score and
292 + // empty() discarded it, so a post the editor scored as 0 kept
293 + // whatever the calculator had produced instead (#394). Both params
294 + // already default to null above, so null means "not sent".
295 + if (null !== $readability_score) {
296 + $score_data['readability_score'] = $readability_score;
297 + }
298 + if (null !== $content_quality) {
299 + $score_data['content_quality'] = $content_quality;
300 + }
301 +
188 302 // Save score if requested
189 303 if ($save_score) {
190 304 $user_id = get_current_user_id();
191 305 $score_id = $this->calculator->save_score($post_id, $user_id, $score_data);
192 -
306 +
193 307 if ($score_id) {
194 308 $score_data['score_id'] = $score_id;
195 309 }
196 310 }
197 -
311 +
198 312 return new WP_REST_Response([
199 313 'success' => true,
200 314 'data' => $score_data,
201 315 ], 200);
202 -
316 +
203 317 } catch (\Exception $e) {
204 318 return new WP_Error(
205 319 'calculation_failed',
206 320 'Failed to calculate SEO score: ' . $e->getMessage(),