PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.28.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.28.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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-brand-visibility-endpoint.php

class-brand-visibility-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.28.0, at includes/api/class-brand-visibility-endpoint.php

631 lines 24.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Brand Visibility v2 REST endpoints.
4 *
5 * Config (brand profile, competitors, questions, platforms, keys), AI question
6 * generation, run start, and progress/results reads.
7 *
8 * Every write is clamped to the current plan HERE as well as in the UI: the UI
9 * limit is a courtesy, this one is the rule. Runs are started asynchronously —
10 * `POST /run` returns a run id immediately and the client polls, because a
11 * full analysis is 100+ provider calls and no REST request should hold that.
12 *
13 * @package ThinkRank\API
14 * @since 1.28.0
15 */
16
17 declare(strict_types=1);
18
19 namespace ThinkRank\API;
20
21 use ThinkRank\AI\Brand_Visibility_Providers;
22 use ThinkRank\AI\Brand_Visibility_Runner;
23 use ThinkRank\AI\Brand_Visibility_Scorer;
24 use ThinkRank\AI\Manager as AI_Manager;
25 use ThinkRank\Core\Plan_Config;
26 use ThinkRank\Core\Settings;
27 use WP_REST_Controller;
28 use WP_REST_Request;
29 use WP_REST_Response;
30
31 if (!defined('ABSPATH')) {
32 exit;
33 }
34
35 /**
36 * REST controller for Brand Visibility v2.
37 */
38 class Brand_Visibility_Endpoint extends WP_REST_Controller {
39
40 /**
41 * Route namespace.
42 *
43 * @var string
44 */
45 protected $namespace = 'thinkrank/v1';
46
47 /**
48 * Route base.
49 *
50 * @var string
51 */
52 protected $rest_base = 'brand-visibility';
53
54 /**
55 * Admin-only: these routes read and write API keys and spend the user's
56 * AI credits.
57 *
58 * @return bool
59 */
60 public function check_admin_permissions(): bool {
61 return current_user_can('manage_options');
62 }
63
64 /**
65 * Hard ceiling on saved questions, matching the wizard.
66 */
67 private const MAX_QUERIES = 12;
68
69 /**
70 * Register routes.
71 *
72 * @return void
73 */
74 public function register_routes(): void {
75 register_rest_route($this->namespace, '/' . $this->rest_base . '/config', [
76 [
77 'methods' => 'GET',
78 'callback' => [$this, 'get_config'],
79 'permission_callback' => [$this, 'check_admin_permissions'],
80 ],
81 [
82 'methods' => 'POST',
83 'callback' => [$this, 'save_config'],
84 'permission_callback' => [$this, 'check_admin_permissions'],
85 ],
86 ]);
87
88 register_rest_route($this->namespace, '/' . $this->rest_base . '/generate-queries', [
89 'methods' => 'POST',
90 'callback' => [$this, 'generate_queries'],
91 'permission_callback' => [$this, 'check_admin_permissions'],
92 ]);
93
94 register_rest_route($this->namespace, '/' . $this->rest_base . '/run', [
95 'methods' => 'POST',
96 'callback' => [$this, 'start_run'],
97 'permission_callback' => [$this, 'check_admin_permissions'],
98 ]);
99
100 register_rest_route($this->namespace, '/' . $this->rest_base . '/run/(?P<id>\d+)', [
101 'methods' => 'GET',
102 'callback' => [$this, 'get_run'],
103 'permission_callback' => [$this, 'check_admin_permissions'],
104 ]);
105
106 register_rest_route($this->namespace, '/' . $this->rest_base . '/runs', [
107 'methods' => 'GET',
108 'callback' => [$this, 'get_runs'],
109 'permission_callback' => [$this, 'check_admin_permissions'],
110 ]);
111 }
112
113 /**
114 * Current configuration, plan caps and platform availability.
115 *
116 * @return WP_REST_Response
117 */
118 public function get_config(): WP_REST_Response {
119 $settings = Settings::instance();
120 $caps = Plan_Config::ai_visibility();
121 $providers = new Brand_Visibility_Providers($settings);
122
123 $brand = (string) $settings->get('bv_brand_name', '');
124
125 return new WP_REST_Response([
126 'success' => true,
127 'data' => [
128 'brand' => '' !== $brand ? $brand : (string) get_bloginfo('name'),
129 'variants' => (array) $settings->get('bv_variants', []),
130 'location' => (string) $settings->get('bv_location', ''),
131 'category' => (string) $settings->get('bv_category', ''),
132 'description' => (string) $settings->get('bv_description', (string) get_bloginfo('description')),
133 'competitors' => (array) $settings->get('bv_competitors', []),
134 'queries' => (array) $settings->get('bv_queries', []),
135 'platforms' => (array) $settings->get('bv_platforms', ['chatgpt']),
136 'samples' => (int) $settings->get('bv_samples', 1),
137 'host' => (string) wp_parse_url(home_url(), PHP_URL_HOST),
138 'configured' => !empty($settings->get('bv_queries', [])),
139 // Which platforms have a usable key, and whether it's their own
140 // or borrowed from the site-wide provider.
141 'available_platforms' => $providers->available_platforms(),
142 // Keys are never returned in full — only whether one is stored
143 // and a masked preview (e.g. "sk-pr••••••••ioA") for display.
144 'keys_set' => $this->keys_set($settings),
145 'keys_masked' => $this->keys_masked($settings),
146 // Effective model per platform plus the ids the picker may offer.
147 'models' => $providers->models(),
148 'model_options' => Brand_Visibility_Providers::MODEL_CHOICES,
149 'plan' => $caps,
150 'is_pro' => Plan_Config::is_pro(),
151 'query_types' => Brand_Visibility_Scorer::QUERY_TYPES,
152 'weights' => Brand_Visibility_Scorer::WEIGHTS,
153 ],
154 ], 200);
155 }
156
157 /**
158 * Persist configuration, clamped to the plan.
159 *
160 * @param WP_REST_Request $request Request.
161 * @return WP_REST_Response
162 */
163 public function save_config(WP_REST_Request $request): WP_REST_Response {
164 $settings = Settings::instance();
165 $caps = Plan_Config::ai_visibility();
166
167 $params = $request->get_json_params();
168 if (!is_array($params)) {
169 $params = $request->get_params();
170 }
171
172 if (isset($params['brand'])) {
173 $settings->set('bv_brand_name', sanitize_text_field((string) $params['brand']));
174 }
175 if (isset($params['location'])) {
176 $settings->set('bv_location', sanitize_text_field((string) $params['location']));
177 }
178 if (isset($params['category'])) {
179 $settings->set('bv_category', sanitize_text_field((string) $params['category']));
180 }
181 if (isset($params['description'])) {
182 $settings->set('bv_description', sanitize_textarea_field((string) $params['description']));
183 }
184
185 if (isset($params['variants']) && is_array($params['variants'])) {
186 $variants = array_values(array_filter(array_map(
187 static fn($v): string => sanitize_text_field((string) $v),
188 $params['variants']
189 )));
190 $settings->set('bv_variants', array_slice($variants, 0, 10));
191 }
192
193 if (isset($params['competitors']) && is_array($params['competitors'])) {
194 $max = (int) ($caps['brand_competitors'] ?? 0);
195
196 $competitors = [];
197 foreach ($params['competitors'] as $competitor) {
198 $name = sanitize_text_field((string) ($competitor['name'] ?? ''));
199 if ('' === $name) {
200 continue;
201 }
202 $competitors[] = [
203 'name' => $name,
204 'url' => esc_url_raw((string) ($competitor['url'] ?? '')),
205 ];
206 }
207
208 $settings->set('bv_competitors', $max > 0 ? array_slice($competitors, 0, $max) : []);
209 }
210
211 if (isset($params['queries']) && is_array($params['queries'])) {
212 $limit = $this->query_limit($caps);
213
214 $queries = [];
215 foreach ($params['queries'] as $query) {
216 $text = sanitize_text_field((string) ($query['text'] ?? $query));
217 if ('' === $text) {
218 continue;
219 }
220 $type = sanitize_key((string) ($query['type'] ?? 'branded'));
221 if (!in_array($type, Brand_Visibility_Scorer::QUERY_TYPES, true)) {
222 $type = 'branded';
223 }
224 $queries[] = ['text' => $text, 'type' => $type];
225 }
226
227 $settings->set('bv_queries', array_slice($queries, 0, $limit));
228 }
229
230 if (isset($params['platforms']) && is_array($params['platforms'])) {
231 $allowed = array_keys(Brand_Visibility_Providers::PLATFORMS);
232 $chosen = array_values(array_intersect(
233 array_map('sanitize_key', $params['platforms']),
234 $allowed
235 ));
236
237 $max = (int) ($caps['brand_max_platforms'] ?? 1);
238 $settings->set('bv_platforms', array_slice($chosen, 0, max(1, $max)));
239 }
240
241 if (isset($params['samples'])) {
242 $max = max(1, (int) ($caps['brand_max_samples'] ?? 1));
243 $settings->set('bv_samples', max(1, min($max, (int) $params['samples'])));
244 }
245
246 // Per-platform model choice. An empty string resets to the platform
247 // default; an id outside the offered list is ignored.
248 if (isset($params['models']) && is_array($params['models'])) {
249 foreach ($params['models'] as $platform => $model) {
250 $platform = sanitize_key((string) $platform);
251 if (!isset(Brand_Visibility_Providers::PLATFORMS[$platform])) {
252 continue;
253 }
254
255 $model = sanitize_text_field((string) $model);
256 if ('' !== $model && !Brand_Visibility_Providers::is_valid_model($platform, $model)) {
257 continue;
258 }
259
260 $settings->set(Brand_Visibility_Providers::model_option($platform), $model);
261 }
262 }
263
264 // Per-platform API keys. An empty string clears a key; a key is never
265 // echoed back by get_config().
266 if (isset($params['keys']) && is_array($params['keys'])) {
267 foreach ($params['keys'] as $platform => $key) {
268 $platform = sanitize_key((string) $platform);
269 if (!isset(Brand_Visibility_Providers::PLATFORMS[$platform])) {
270 continue;
271 }
272 $settings->set(
273 Brand_Visibility_Providers::key_option($platform),
274 sanitize_text_field((string) $key)
275 );
276 }
277 }
278
279 return $this->get_config();
280 }
281
282 /**
283 * Generate starter questions with AI, grouped by type.
284 *
285 * Cheaper and better than making the user invent them: the four types
286 * (branded, category, problem, comparison) are what separate "do people
287 * find me when they already know my name" from "do people find me at all".
288 *
289 * @param WP_REST_Request $request Request.
290 * @return WP_REST_Response
291 */
292 public function generate_queries(WP_REST_Request $request): WP_REST_Response {
293 $params = $request->get_json_params() ?: [];
294
295 $brand = sanitize_text_field((string) ($params['brand'] ?? get_bloginfo('name')));
296 $category = sanitize_text_field((string) ($params['category'] ?? ''));
297 $location = sanitize_text_field((string) ($params['location'] ?? ''));
298 $description = sanitize_textarea_field((string) ($params['description'] ?? ''));
299
300 $competitors = [];
301 foreach ((array) ($params['competitors'] ?? []) as $competitor) {
302 $name = sanitize_text_field((string) ($competitor['name'] ?? ''));
303 if ('' !== $name) {
304 $competitors[] = $name;
305 }
306 }
307
308 try {
309 $ai = new AI_Manager();
310 $ai->initialize_client();
311
312 $prompt = $this->query_prompt($brand, $category, $location, $description, $competitors);
313 $result = $ai->answer_prompt($prompt, 4000, ['reasoning_effort' => 'minimal']);
314 $text = (string) ($result['ai_text'] ?? '');
315
316 $queries = $this->parse_generated_queries($text);
317
318 // Comparison questions are deterministic — build them from the
319 // competitor list rather than trusting the model to echo names.
320 foreach ($competitors as $name) {
321 $queries[] = ['text' => sprintf('%s vs %s', $brand, $name), 'type' => 'comparison'];
322 }
323
324 if (empty($queries)) {
325 throw new \Exception(esc_html__('The AI did not return any usable questions.', 'thinkrank'));
326 }
327
328 return new WP_REST_Response([
329 'success' => true,
330 'data' => ['queries' => array_slice($queries, 0, self::MAX_QUERIES)],
331 ], 200);
332 } catch (\Throwable $e) {
333 return new WP_REST_Response([
334 'success' => false,
335 'message' => $e->getMessage(),
336 // The wizard stays usable without AI — the user can type their
337 // own questions, so this is a soft failure.
338 'data' => ['queries' => $this->fallback_queries($brand, $category, $location, $competitors)],
339 ], 200);
340 }
341 }
342
343 /**
344 * Start an analysis run.
345 *
346 * @param WP_REST_Request $request Request.
347 * @return WP_REST_Response
348 */
349 public function start_run(WP_REST_Request $request): WP_REST_Response {
350 $settings = Settings::instance();
351 $caps = Plan_Config::ai_visibility();
352
353 $platforms = (array) $settings->get('bv_platforms', ['chatgpt']);
354 $providers = new Brand_Visibility_Providers($settings);
355
356 // Drop platforms whose key has since been removed, so a run can't be
357 // scheduled to fail on every task of one platform.
358 $usable = array_values(array_filter(
359 $platforms,
360 static fn($p): bool => '' !== $providers->api_key_for((string) $p)
361 ));
362
363 // Re-clamp to the plan at RUN time, not just at save time: a site that
364 // saved 12 questions across 4 platforms on Pro and then lapsed to free
365 // would otherwise keep firing the Pro-sized batch on every run.
366 $usable = array_slice($usable, 0, max(1, (int) ($caps['brand_max_platforms'] ?? 1)));
367
368 if (empty($usable)) {
369 return new WP_REST_Response([
370 'success' => false,
371 'message' => __('None of the selected AI platforms has an API key configured.', 'thinkrank'),
372 ], 400);
373 }
374
375 $config = [
376 'brand' => (string) $settings->get('bv_brand_name', (string) get_bloginfo('name')),
377 'variants' => (array) $settings->get('bv_variants', []),
378 'competitors' => (int) ($caps['brand_competitors'] ?? 0) > 0
379 ? (array) $settings->get('bv_competitors', [])
380 : [],
381 'queries' => array_slice((array) $settings->get('bv_queries', []), 0, $this->query_limit($caps)),
382 'platforms' => $usable,
383 'samples' => max(1, min(
384 max(1, (int) ($caps['brand_max_samples'] ?? 1)),
385 (int) $settings->get('bv_samples', 1)
386 )),
387 ];
388
389 try {
390 $runner = new Brand_Visibility_Runner($settings, $providers);
391 $run_id = $runner->start($config);
392 } catch (\Throwable $e) {
393 return new WP_REST_Response([
394 'success' => false,
395 'message' => $e->getMessage(),
396 ], 400);
397 }
398
399 return new WP_REST_Response([
400 'success' => true,
401 'data' => [
402 'run_id' => $run_id,
403 'tasks_total' => count(Brand_Visibility_Runner::plan_tasks($config)),
404 ],
405 ], 201);
406 }
407
408 /**
409 * How many questions this plan may run, bounded by the hard ceiling.
410 *
411 * @param array $caps Capability map.
412 * @return int
413 */
414 private function query_limit(array $caps): int {
415 $plan_max = (int) ($caps['brand_max_queries'] ?? 2);
416
417 return $plan_max > 0 ? min($plan_max, self::MAX_QUERIES) : self::MAX_QUERIES;
418 }
419
420 /**
421 * Run progress and (once finished) results.
422 *
423 * @param WP_REST_Request $request Request.
424 * @return WP_REST_Response
425 */
426 public function get_run(WP_REST_Request $request): WP_REST_Response {
427 $runner = new Brand_Visibility_Runner();
428 $run = $runner->get_run((int) $request['id']);
429
430 if (empty($run)) {
431 return new WP_REST_Response([
432 'success' => false,
433 'message' => __('Run not found.', 'thinkrank'),
434 ], 404);
435 }
436
437 $total = max(1, (int) $run['tasks_total']);
438 $finished = (int) $run['tasks_done'] + (int) $run['tasks_failed'];
439
440 return new WP_REST_Response([
441 'success' => true,
442 'data' => [
443 'id' => (int) $run['id'],
444 'status' => (string) $run['status'],
445 'started_at' => (string) $run['started_at'],
446 'finished_at' => (string) ($run['finished_at'] ?? ''),
447 'tasks_total' => (int) $run['tasks_total'],
448 'tasks_done' => (int) $run['tasks_done'],
449 'tasks_failed' => (int) $run['tasks_failed'],
450 'progress' => (int) round(($finished / $total) * 100),
451 'results' => $run['results'],
452 'config' => $run['config'],
453 'error' => (string) ($run['error'] ?? ''),
454 ],
455 ], 200);
456 }
457
458 /**
459 * Run history for the trend chart, clamped to the plan.
460 *
461 * @return WP_REST_Response
462 */
463 public function get_runs(): WP_REST_Response {
464 $caps = Plan_Config::ai_visibility();
465 $limit = (int) ($caps['brand_history_runs'] ?? 1);
466 $runner = new Brand_Visibility_Runner();
467
468 $runs = $runner->recent_runs($limit > 0 ? $limit : 20);
469
470 return new WP_REST_Response([
471 'success' => true,
472 'data' => [
473 'runs' => array_map(static function (array $run): array {
474 return [
475 'id' => (int) $run['id'],
476 'status' => (string) $run['status'],
477 'started_at' => (string) $run['started_at'],
478 'index' => (int) ($run['results']['visibility_index'] ?? 0),
479 'mention_rate' => (float) ($run['results']['mention_rate'] ?? 0),
480 'competitors' => $run['results']['competitors'] ?? [],
481 ];
482 }, $runs),
483 'limited' => $limit > 0,
484 ],
485 ], 200);
486 }
487
488 /**
489 * Which platforms have a dedicated key stored.
490 *
491 * @param Settings $settings Settings.
492 * @return array<string, bool>
493 */
494 private function keys_set(Settings $settings): array {
495 $out = [];
496 foreach (array_keys(Brand_Visibility_Providers::PLATFORMS) as $platform) {
497 $out[$platform] = '' !== trim((string) $settings->get(
498 Brand_Visibility_Providers::key_option($platform),
499 ''
500 ));
501 }
502
503 return $out;
504 }
505
506 /**
507 * Masked preview of each platform's dedicated key, for display.
508 *
509 * Reveals the first 5 and last 3 characters with a bullet run in between
510 * (e.g. "sk-pr••••••••ioA"); keys of 8 chars or fewer are fully masked.
511 * Empty when the platform has no dedicated key of its own.
512 *
513 * @param Settings $settings Settings instance.
514 * @return array<string, string>
515 */
516 private function keys_masked(Settings $settings): array {
517 $out = [];
518 foreach (array_keys(Brand_Visibility_Providers::PLATFORMS) as $platform) {
519 $key = trim((string) $settings->get(
520 Brand_Visibility_Providers::key_option($platform),
521 ''
522 ));
523
524 if ('' === $key) {
525 $out[$platform] = '';
526 } elseif (strlen($key) <= 8) {
527 $out[$platform] = '••••••••';
528 } else {
529 $out[$platform] = substr($key, 0, 5) . '••••••••' . substr($key, -3);
530 }
531 }
532
533 return $out;
534 }
535
536 /**
537 * Prompt for question generation.
538 *
539 * @param string $brand Brand.
540 * @param string $category Category.
541 * @param string $location Location.
542 * @param string $description Short description.
543 * @param string[] $competitors Competitor names.
544 * @return string
545 */
546 private function query_prompt(string $brand, string $category, string $location, string $description, array $competitors): string {
547 return sprintf(
548 "You are helping measure how often an AI assistant mentions a business in its answers.\n\n"
549 . "Business: %s\nCategory: %s\nLocation: %s\nAbout: %s\nCompetitors: %s\n\n"
550 . "Write realistic questions a potential customer would ask an AI assistant. Group them exactly like this, one question per line, no numbering:\n\n"
551 . "BRANDED:\n(3 questions that name the business directly)\n\n"
552 . "CATEGORY:\n(3 questions about the product category and location that do NOT name the business)\n\n"
553 . "PROBLEM:\n(3 questions about a problem this business solves, that do NOT name the business)\n\n"
554 . "Return nothing except those three sections.",
555 $brand,
556 '' !== $category ? $category : 'general',
557 '' !== $location ? $location : 'global',
558 '' !== $description ? $description : 'n/a',
559 !empty($competitors) ? implode(', ', $competitors) : 'none'
560 );
561 }
562
563 /**
564 * Parse the model's sectioned output into typed questions.
565 *
566 * @param string $text Model output.
567 * @return array<int, array{text: string, type: string}>
568 */
569 private function parse_generated_queries(string $text): array {
570 $type = '';
571 $queries = [];
572
573 foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
574 $line = trim($line);
575 if ('' === $line) {
576 continue;
577 }
578
579 $upper = strtoupper($line);
580 foreach (['BRANDED', 'CATEGORY', 'PROBLEM', 'COMPARISON'] as $heading) {
581 if (0 === strpos($upper, $heading)) {
582 $type = strtolower($heading);
583 continue 2;
584 }
585 }
586
587 if ('' === $type) {
588 continue;
589 }
590
591 // Strip list markers the model adds despite instructions.
592 $line = ltrim($line, "-*•0123456789. \t");
593 if (mb_strlen($line) < 8) {
594 continue;
595 }
596
597 $queries[] = ['text' => sanitize_text_field($line), 'type' => $type];
598 }
599
600 return $queries;
601 }
602
603 /**
604 * Deterministic questions used when AI generation is unavailable.
605 *
606 * @param string $brand Brand.
607 * @param string $category Category.
608 * @param string $location Location.
609 * @param string[] $competitors Competitor names.
610 * @return array<int, array{text: string, type: string}>
611 */
612 private function fallback_queries(string $brand, string $category, string $location, array $competitors): array {
613 $subject = '' !== $category ? $category : __('products', 'thinkrank');
614 $where = '' !== $location ? sprintf(' in %s', $location) : '';
615
616 $queries = [
617 ['text' => sprintf('What is %s and what does it offer?', $brand), 'type' => 'branded'],
618 ['text' => sprintf('Is %s a good choice for %s?', $brand, $subject), 'type' => 'branded'],
619 ['text' => sprintf('What are the best %s%s?', $subject, $where), 'type' => 'category'],
620 ['text' => sprintf('Where can I buy %s%s?', $subject, $where), 'type' => 'category'],
621 ['text' => sprintf('How do I choose the right %s?', $subject), 'type' => 'problem'],
622 ];
623
624 foreach ($competitors as $name) {
625 $queries[] = ['text' => sprintf('%s vs %s', $brand, $name), 'type' => 'comparison'];
626 }
627
628 return $queries;
629 }
630 }
631