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

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