PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.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 2.1.0, at includes/api/class-brand-visibility-endpoint.php

642 lines 24.6 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 // `(string) $array` is the literal "Array" plus a PHP notice, so
217 // an entry that is an array without a `text` key was stored as a
218 // query reading "Array" (#394). Skip what cannot be read as text.
219 $raw = is_array($query) ? ($query['text'] ?? null) : $query;
220
221 if (!is_scalar($raw)) {
222 continue;
223 }
224
225 $text = sanitize_text_field((string) $raw);
226 if ('' === $text) {
227 continue;
228 }
229 $type = sanitize_key((string) ($query['type'] ?? 'branded'));
230 if (!in_array($type, Brand_Visibility_Scorer::QUERY_TYPES, true)) {
231 $type = 'branded';
232 }
233 $queries[] = ['text' => $text, 'type' => $type];
234 }
235
236 $settings->set('bv_queries', array_slice($queries, 0, $limit));
237 }
238
239 if (isset($params['platforms']) && is_array($params['platforms'])) {
240 $allowed = array_keys(Brand_Visibility_Providers::PLATFORMS);
241 $chosen = array_values(array_intersect(
242 array_map('sanitize_key', $params['platforms']),
243 $allowed
244 ));
245
246 $max = (int) ($caps['brand_max_platforms'] ?? 1);
247 $settings->set('bv_platforms', array_slice($chosen, 0, max(1, $max)));
248 }
249
250 if (isset($params['samples'])) {
251 $max = max(1, (int) ($caps['brand_max_samples'] ?? 1));
252 $settings->set('bv_samples', max(1, min($max, (int) $params['samples'])));
253 }
254
255 // Per-platform model choice. An empty string resets to the platform
256 // default; an id outside the offered list is ignored.
257 if (isset($params['models']) && is_array($params['models'])) {
258 foreach ($params['models'] as $platform => $model) {
259 $platform = sanitize_key((string) $platform);
260 if (!isset(Brand_Visibility_Providers::PLATFORMS[$platform])) {
261 continue;
262 }
263
264 $model = sanitize_text_field((string) $model);
265 if ('' !== $model && !Brand_Visibility_Providers::is_valid_model($platform, $model)) {
266 continue;
267 }
268
269 $settings->set(Brand_Visibility_Providers::model_option($platform), $model);
270 }
271 }
272
273 // Per-platform API keys. An empty string clears a key; a key is never
274 // echoed back by get_config().
275 if (isset($params['keys']) && is_array($params['keys'])) {
276 foreach ($params['keys'] as $platform => $key) {
277 $platform = sanitize_key((string) $platform);
278 if (!isset(Brand_Visibility_Providers::PLATFORMS[$platform])) {
279 continue;
280 }
281 $settings->set(
282 Brand_Visibility_Providers::key_option($platform),
283 sanitize_text_field((string) $key)
284 );
285 }
286 }
287
288 return $this->get_config();
289 }
290
291 /**
292 * Generate starter questions with AI, grouped by type.
293 *
294 * Cheaper and better than making the user invent them: the four types
295 * (branded, category, problem, comparison) are what separate "do people
296 * find me when they already know my name" from "do people find me at all".
297 *
298 * @param WP_REST_Request $request Request.
299 * @return WP_REST_Response
300 *
301 * @throws \Exception On failure.
302 */
303 public function generate_queries(WP_REST_Request $request): WP_REST_Response {
304 $params = $request->get_json_params() ?: [];
305
306 $brand = sanitize_text_field((string) ($params['brand'] ?? get_bloginfo('name')));
307 $category = sanitize_text_field((string) ($params['category'] ?? ''));
308 $location = sanitize_text_field((string) ($params['location'] ?? ''));
309 $description = sanitize_textarea_field((string) ($params['description'] ?? ''));
310
311 $competitors = [];
312 foreach ((array) ($params['competitors'] ?? []) as $competitor) {
313 $name = sanitize_text_field((string) ($competitor['name'] ?? ''));
314 if ('' !== $name) {
315 $competitors[] = $name;
316 }
317 }
318
319 try {
320 $ai = new AI_Manager();
321 $ai->initialize_client();
322
323 $prompt = $this->query_prompt($brand, $category, $location, $description, $competitors);
324 $result = $ai->answer_prompt($prompt, 4000, ['reasoning_effort' => 'minimal']);
325 $text = (string) ($result['ai_text'] ?? '');
326
327 $queries = $this->parse_generated_queries($text);
328
329 // Comparison questions are deterministic — build them from the
330 // competitor list rather than trusting the model to echo names.
331 foreach ($competitors as $name) {
332 $queries[] = ['text' => sprintf('%s vs %s', $brand, $name), 'type' => 'comparison'];
333 }
334
335 if (empty($queries)) {
336 throw new \Exception(esc_html__('The AI did not return any usable questions.', 'thinkrank'));
337 }
338
339 return new WP_REST_Response([
340 'success' => true,
341 'data' => ['queries' => array_slice($queries, 0, self::MAX_QUERIES)],
342 ], 200);
343 } catch (\Throwable $e) {
344 return new WP_REST_Response([
345 'success' => false,
346 'message' => $e->getMessage(),
347 // The wizard stays usable without AI — the user can type their
348 // own questions, so this is a soft failure.
349 'data' => ['queries' => $this->fallback_queries($brand, $category, $location, $competitors)],
350 ], 200);
351 }
352 }
353
354 /**
355 * Start an analysis run.
356 *
357 * @param WP_REST_Request $request Request.
358 * @return WP_REST_Response
359 */
360 public function start_run(WP_REST_Request $request): WP_REST_Response {
361 $settings = Settings::instance();
362 $caps = Plan_Config::ai_visibility();
363
364 $platforms = (array) $settings->get('bv_platforms', ['chatgpt']);
365 $providers = new Brand_Visibility_Providers($settings);
366
367 // Drop platforms whose key has since been removed, so a run can't be
368 // scheduled to fail on every task of one platform.
369 $usable = array_values(array_filter(
370 $platforms,
371 static fn($p): bool => '' !== $providers->api_key_for((string) $p)
372 ));
373
374 // Re-clamp to the plan at RUN time, not just at save time: a site that
375 // saved 12 questions across 4 platforms on Pro and then lapsed to free
376 // would otherwise keep firing the Pro-sized batch on every run.
377 $usable = array_slice($usable, 0, max(1, (int) ($caps['brand_max_platforms'] ?? 1)));
378
379 if (empty($usable)) {
380 return new WP_REST_Response([
381 'success' => false,
382 'message' => __('None of the selected AI platforms has an API key configured.', 'thinkrank'),
383 ], 400);
384 }
385
386 $config = [
387 'brand' => (string) $settings->get('bv_brand_name', (string) get_bloginfo('name')),
388 'variants' => (array) $settings->get('bv_variants', []),
389 'competitors' => (int) ($caps['brand_competitors'] ?? 0) > 0
390 ? (array) $settings->get('bv_competitors', [])
391 : [],
392 'queries' => array_slice((array) $settings->get('bv_queries', []), 0, $this->query_limit($caps)),
393 'platforms' => $usable,
394 'samples' => max(1, min(
395 max(1, (int) ($caps['brand_max_samples'] ?? 1)),
396 (int) $settings->get('bv_samples', 1)
397 )),
398 ];
399
400 try {
401 $runner = new Brand_Visibility_Runner($settings, $providers);
402 $run_id = $runner->start($config);
403 } catch (\Throwable $e) {
404 return new WP_REST_Response([
405 'success' => false,
406 'message' => $e->getMessage(),
407 ], 400);
408 }
409
410 return new WP_REST_Response([
411 'success' => true,
412 'data' => [
413 'run_id' => $run_id,
414 'tasks_total' => count(Brand_Visibility_Runner::plan_tasks($config)),
415 ],
416 ], 201);
417 }
418
419 /**
420 * How many questions this plan may run, bounded by the hard ceiling.
421 *
422 * @param array $caps Capability map.
423 * @return int
424 */
425 private function query_limit(array $caps): int {
426 $plan_max = (int) ($caps['brand_max_queries'] ?? 2);
427
428 return $plan_max > 0 ? min($plan_max, self::MAX_QUERIES) : self::MAX_QUERIES;
429 }
430
431 /**
432 * Run progress and (once finished) results.
433 *
434 * @param WP_REST_Request $request Request.
435 * @return WP_REST_Response
436 */
437 public function get_run(WP_REST_Request $request): WP_REST_Response {
438 $runner = new Brand_Visibility_Runner();
439 $run = $runner->get_run((int) $request['id']);
440
441 if (empty($run)) {
442 return new WP_REST_Response([
443 'success' => false,
444 'message' => __('Run not found.', 'thinkrank'),
445 ], 404);
446 }
447
448 $total = max(1, (int) $run['tasks_total']);
449 $finished = (int) $run['tasks_done'] + (int) $run['tasks_failed'];
450
451 return new WP_REST_Response([
452 'success' => true,
453 'data' => [
454 'id' => (int) $run['id'],
455 'status' => (string) $run['status'],
456 'started_at' => (string) $run['started_at'],
457 'finished_at' => (string) ($run['finished_at'] ?? ''),
458 'tasks_total' => (int) $run['tasks_total'],
459 'tasks_done' => (int) $run['tasks_done'],
460 'tasks_failed' => (int) $run['tasks_failed'],
461 'progress' => (int) round(($finished / $total) * 100),
462 'results' => $run['results'],
463 'config' => $run['config'],
464 'error' => (string) ($run['error'] ?? ''),
465 ],
466 ], 200);
467 }
468
469 /**
470 * Run history for the trend chart, clamped to the plan.
471 *
472 * @return WP_REST_Response
473 */
474 public function get_runs(): WP_REST_Response {
475 $caps = Plan_Config::ai_visibility();
476 $limit = (int) ($caps['brand_history_runs'] ?? 1);
477 $runner = new Brand_Visibility_Runner();
478
479 $runs = $runner->recent_runs($limit > 0 ? $limit : 20);
480
481 return new WP_REST_Response([
482 'success' => true,
483 'data' => [
484 'runs' => array_map(static function (array $run): array {
485 return [
486 'id' => (int) $run['id'],
487 'status' => (string) $run['status'],
488 'started_at' => (string) $run['started_at'],
489 'index' => (int) ($run['results']['visibility_index'] ?? 0),
490 'mention_rate' => (float) ($run['results']['mention_rate'] ?? 0),
491 'competitors' => $run['results']['competitors'] ?? [],
492 ];
493 }, $runs),
494 'limited' => $limit > 0,
495 ],
496 ], 200);
497 }
498
499 /**
500 * Which platforms have a dedicated key stored.
501 *
502 * @param Settings $settings Settings.
503 * @return array<string, bool>
504 */
505 private function keys_set(Settings $settings): array {
506 $out = [];
507 foreach (array_keys(Brand_Visibility_Providers::PLATFORMS) as $platform) {
508 $out[$platform] = '' !== trim((string) $settings->get(
509 Brand_Visibility_Providers::key_option($platform),
510 ''
511 ));
512 }
513
514 return $out;
515 }
516
517 /**
518 * Masked preview of each platform's dedicated key, for display.
519 *
520 * Reveals the first 5 and last 3 characters with a bullet run in between
521 * (e.g. "sk-pr••••••••ioA"); keys of 8 chars or fewer are fully masked.
522 * Empty when the platform has no dedicated key of its own.
523 *
524 * @param Settings $settings Settings instance.
525 * @return array<string, string>
526 */
527 private function keys_masked(Settings $settings): array {
528 $out = [];
529 foreach (array_keys(Brand_Visibility_Providers::PLATFORMS) as $platform) {
530 $key = trim((string) $settings->get(
531 Brand_Visibility_Providers::key_option($platform),
532 ''
533 ));
534
535 if ('' === $key) {
536 $out[$platform] = '';
537 } elseif (strlen($key) <= 8) {
538 $out[$platform] = '••••••••';
539 } else {
540 $out[$platform] = substr($key, 0, 5) . '••••••••' . substr($key, -3);
541 }
542 }
543
544 return $out;
545 }
546
547 /**
548 * Prompt for question generation.
549 *
550 * @param string $brand Brand.
551 * @param string $category Category.
552 * @param string $location Location.
553 * @param string $description Short description.
554 * @param string[] $competitors Competitor names.
555 * @return string
556 */
557 private function query_prompt(string $brand, string $category, string $location, string $description, array $competitors): string {
558 return sprintf(
559 "You are helping measure how often an AI assistant mentions a business in its answers.\n\n"
560 . "Business: %s\nCategory: %s\nLocation: %s\nAbout: %s\nCompetitors: %s\n\n"
561 . "Write realistic questions a potential customer would ask an AI assistant. Group them exactly like this, one question per line, no numbering:\n\n"
562 . "BRANDED:\n(3 questions that name the business directly)\n\n"
563 . "CATEGORY:\n(3 questions about the product category and location that do NOT name the business)\n\n"
564 . "PROBLEM:\n(3 questions about a problem this business solves, that do NOT name the business)\n\n"
565 . "Return nothing except those three sections.",
566 $brand,
567 '' !== $category ? $category : 'general',
568 '' !== $location ? $location : 'global',
569 '' !== $description ? $description : 'n/a',
570 !empty($competitors) ? implode(', ', $competitors) : 'none'
571 );
572 }
573
574 /**
575 * Parse the model's sectioned output into typed questions.
576 *
577 * @param string $text Model output.
578 * @return array<int, array{text: string, type: string}>
579 */
580 private function parse_generated_queries(string $text): array {
581 $type = '';
582 $queries = [];
583
584 foreach (preg_split('/\r\n|\r|\n/', $text) as $line) {
585 $line = trim($line);
586 if ('' === $line) {
587 continue;
588 }
589
590 $upper = strtoupper($line);
591 foreach (['BRANDED', 'CATEGORY', 'PROBLEM', 'COMPARISON'] as $heading) {
592 if (0 === strpos($upper, $heading)) {
593 $type = strtolower($heading);
594 continue 2;
595 }
596 }
597
598 if ('' === $type) {
599 continue;
600 }
601
602 // Strip list markers the model adds despite instructions.
603 $line = ltrim($line, "-*•0123456789. \t");
604 if (mb_strlen($line) < 8) {
605 continue;
606 }
607
608 $queries[] = ['text' => sanitize_text_field($line), 'type' => $type];
609 }
610
611 return $queries;
612 }
613
614 /**
615 * Deterministic questions used when AI generation is unavailable.
616 *
617 * @param string $brand Brand.
618 * @param string $category Category.
619 * @param string $location Location.
620 * @param string[] $competitors Competitor names.
621 * @return array<int, array{text: string, type: string}>
622 */
623 private function fallback_queries(string $brand, string $category, string $location, array $competitors): array {
624 $subject = '' !== $category ? $category : __('products', 'thinkrank');
625 $where = '' !== $location ? sprintf(' in %s', $location) : '';
626
627 $queries = [
628 ['text' => sprintf('What is %s and what does it offer?', $brand), 'type' => 'branded'],
629 ['text' => sprintf('Is %s a good choice for %s?', $brand, $subject), 'type' => 'branded'],
630 ['text' => sprintf('What are the best %s%s?', $subject, $where), 'type' => 'category'],
631 ['text' => sprintf('Where can I buy %s%s?', $subject, $where), 'type' => 'category'],
632 ['text' => sprintf('How do I choose the right %s?', $subject), 'type' => 'problem'],
633 ];
634
635 foreach ($competitors as $name) {
636 $queries[] = ['text' => sprintf('%s vs %s', $brand, $name), 'type' => 'comparison'];
637 }
638
639 return $queries;
640 }
641 }
642