PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.2.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.2.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 / ai / class-brand-visibility-scorer.php

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

537 lines 19.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 — scoring engine.
4 *
5 * Pure functions, no WordPress: every number the dashboard shows is computed
6 * here from a flat list of completed task rows, so the maths is unit-testable
7 * and the formulas live in exactly one place.
8 *
9 * Why sampling matters: an LLM answers the same question differently run to
10 * run, so a single probe is a coin flip, not a measurement. Every metric here
11 * is therefore a RATE over samples (mentions / samples), which is what makes
12 * "45%" mean something. The v1 single-shot Yes/No is the noise problem this
13 * replaces.
14 *
15 * @package ThinkRank\AI
16 * @since 1.28.0
17 */
18
19 declare(strict_types=1);
20
21 namespace ThinkRank\AI;
22
23 if (!defined('ABSPATH')) {
24 exit;
25 }
26
27 /**
28 * Computes brand-visibility verdicts and run aggregates.
29 */
30 final class Brand_Visibility_Scorer {
31
32 /**
33 * Visibility Index weights. Published in the UI tooltip on purpose — a
34 * score nobody can explain is a score nobody trusts.
35 *
36 * Mention rate dominates because "does the AI name you at all" is the
37 * question; share of voice is next because being named alongside three
38 * competitors is not the same as being named alone.
39 */
40 public const WEIGHTS = [
41 'mention_rate' => 0.45,
42 'share_of_voice' => 0.25,
43 'citation_rate' => 0.15,
44 'sentiment' => 0.15,
45 ];
46
47 /**
48 * Query types, in the order the wizard presents them.
49 */
50 public const QUERY_TYPES = ['branded', 'category', 'problem', 'comparison'];
51
52 /**
53 * Whether a name appears in an answer as a WORD, not a substring.
54 *
55 * Substring matching is why naive checkers over-report: the brand "Ace"
56 * would match "surface", and a competitor "On" would match every sentence.
57 * Boundaries are checked against non-word characters so multi-word and
58 * punctuated brands ("Raymond Coffee", "Yoast SEO") still match, while
59 * `str_contains` false positives don't.
60 *
61 * @param string $answer Answer text to search.
62 * @param string $name Brand or competitor name.
63 * @return bool True when the name appears as a discrete word/phrase.
64 */
65 public static function name_appears(string $answer, string $name): bool {
66 $name = trim($name);
67 if ('' === $name || '' === trim($answer)) {
68 return false;
69 }
70
71 // \b is unreliable for names ending in punctuation or non-ASCII, so
72 // assert "not preceded/followed by a word character" explicitly.
73 $pattern = '/(?<![\p{L}\p{N}])' . preg_quote($name, '/') . '(?![\p{L}\p{N}])/ui';
74
75 return 1 === preg_match($pattern, $answer);
76 }
77
78 /**
79 * Whether the brand (or any of its variants) is mentioned.
80 *
81 * Variants exist because AI answers rarely use a brand's exact legal
82 * string — "ThinkRank", "Think Rank" and "ThinkRank SEO" are the same
83 * brand to a reader, and counting only the first understates visibility.
84 *
85 * @param string $answer Answer text.
86 * @param string $brand Primary brand name.
87 * @param string[] $variants Alternate spellings/aliases.
88 * @return bool
89 */
90 public static function is_mentioned(string $answer, string $brand, array $variants = []): bool {
91 foreach (array_merge([$brand], $variants) as $candidate) {
92 if (self::name_appears($answer, (string) $candidate)) {
93 return true;
94 }
95 }
96
97 return false;
98 }
99
100 /**
101 * Whether the answer cites the site's domain.
102 *
103 * Host comparison is done on the bare host (no scheme, no www) and as a
104 * plain case-insensitive search rather than a word match, because URLs
105 * legitimately appear glued to punctuation and paths.
106 *
107 * @param string $answer Answer text.
108 * @param string $host Site host, e.g. example.com.
109 * @return bool
110 */
111 public static function is_cited(string $answer, string $host): bool {
112 $bare = self::bare_host($host);
113
114 return '' !== $bare && false !== stripos($answer, $bare);
115 }
116
117 /**
118 * Competitors named in an answer.
119 *
120 * @param string $answer Answer text.
121 * @param array $competitors List of ['name' => string, 'url' => string].
122 * @return string[] Names found, in config order.
123 */
124 public static function competitors_in(string $answer, array $competitors): array {
125 $found = [];
126
127 foreach ($competitors as $competitor) {
128 $name = (string) ($competitor['name'] ?? '');
129 if ('' === $name) {
130 continue;
131 }
132
133 $host = self::bare_host((string) ($competitor['url'] ?? ''));
134 $hit = self::name_appears($answer, $name)
135 || ('' !== $host && false !== stripos($answer, $host));
136
137 if ($hit) {
138 $found[] = $name;
139 }
140 }
141
142 return $found;
143 }
144
145 /**
146 * Excerpt centred on the first brand hit, so the UI can show HOW the brand
147 * came up rather than only that it did.
148 *
149 * @param string $answer Answer text.
150 * @param string $brand Brand name.
151 * @param string[] $variants Alternates.
152 * @return string Excerpt (may be empty when nothing matched).
153 */
154 public static function excerpt(string $answer, string $brand, array $variants = []): string {
155 foreach (array_merge([$brand], $variants) as $candidate) {
156 $candidate = trim((string) $candidate);
157 if ('' === $candidate) {
158 continue;
159 }
160
161 $pos = stripos($answer, $candidate);
162 if (false === $pos) {
163 continue;
164 }
165
166 $start = max(0, $pos - 120);
167 $excerpt = trim(substr($answer, $start, 300));
168
169 if ($start > 0) {
170 $excerpt = '' . $excerpt;
171 }
172 if ($start + 300 < strlen($answer)) {
173 $excerpt .= '';
174 }
175
176 return $excerpt;
177 }
178
179 return '';
180 }
181
182 /**
183 * Aggregate completed tasks into the run's results payload.
184 *
185 * Only tasks that actually produced an answer are counted. A failed probe
186 * is NOT a "not mentioned" — folding errors into the denominator would
187 * silently depress every score, which is exactly the false-negative class
188 * of bug that made v1 untrustworthy.
189 *
190 * @param array $tasks Task rows: query_text, query_type, platform,
191 * status, mentioned, cited, sentiment, competitors.
192 * @param array $config Run config: competitors[].
193 * @return array Aggregates for the dashboard.
194 */
195 public static function aggregate(array $tasks, array $config = []): array {
196 $competitors = $config['competitors'] ?? [];
197
198 $done = array_values(array_filter(
199 $tasks,
200 static fn(array $t): bool => 'done' === ($t['status'] ?? '')
201 ));
202
203 $samples = count($done);
204
205 if (0 === $samples) {
206 return self::empty_results($competitors);
207 }
208
209 $mentions = 0;
210 $citations = 0;
211 $sentiment = ['positive' => 0, 'neutral' => 0, 'negative' => 0];
212
213 // Competitor mention tallies, keyed by name and seeded so a competitor
214 // that never appears still shows up in the leaderboard at 0.
215 $competitor_hits = [];
216 foreach ($competitors as $competitor) {
217 $name = (string) ($competitor['name'] ?? '');
218 if ('' !== $name) {
219 $competitor_hits[$name] = 0;
220 }
221 }
222
223 $by_platform = [];
224 $by_query = [];
225
226 foreach ($done as $task) {
227 $mentioned = !empty($task['mentioned']);
228 $cited = !empty($task['cited']);
229
230 $mentions += $mentioned ? 1 : 0;
231 $citations += $cited ? 1 : 0;
232
233 $mood = (string) ($task['sentiment'] ?? '');
234 if ($mentioned && isset($sentiment[$mood])) {
235 $sentiment[$mood]++;
236 }
237
238 foreach (self::task_competitors($task) as $name) {
239 if (!isset($competitor_hits[$name])) {
240 $competitor_hits[$name] = 0;
241 }
242 $competitor_hits[$name]++;
243 }
244
245 $platform = (string) ($task['platform'] ?? 'unknown');
246 $by_platform[$platform] ??= ['platform' => $platform, 'samples' => 0, 'mentions' => 0, 'citations' => 0];
247 $by_platform[$platform]['samples']++;
248 $by_platform[$platform]['mentions'] += $mentioned ? 1 : 0;
249 $by_platform[$platform]['citations'] += $cited ? 1 : 0;
250
251 $query = (string) ($task['query_text'] ?? '');
252 $by_query[$query] ??= [
253 'query' => $query,
254 'type' => (string) ($task['query_type'] ?? 'branded'),
255 'samples' => 0,
256 'mentions' => 0,
257 'citations' => 0,
258 'platforms' => [],
259 ];
260 $by_query[$query]['samples']++;
261 $by_query[$query]['mentions'] += $mentioned ? 1 : 0;
262 $by_query[$query]['citations'] += $cited ? 1 : 0;
263
264 // Track which platform mentions the brand most for this query, so
265 // the table can answer "where am I actually winning?".
266 $by_query[$query]['platforms'][$platform] ??= ['samples' => 0, 'mentions' => 0];
267 $by_query[$query]['platforms'][$platform]['samples']++;
268 $by_query[$query]['platforms'][$platform]['mentions'] += $mentioned ? 1 : 0;
269 }
270
271 $mention_rate = self::rate($mentions, $samples);
272 $citation_rate = self::rate($citations, $samples);
273
274 // Share of voice: your mentions against the whole named field. With no
275 // competitors configured there is no field to share, so SoV is your
276 // mention rate — honest, and it keeps the index meaningful on free.
277 $competitor_total = array_sum($competitor_hits);
278 $share_of_voice = ($mentions + $competitor_total) > 0
279 ? self::rate($mentions, $mentions + $competitor_total)
280 : 0.0;
281
282 // Sentiment share is measured over MENTIONS, not samples: an answer
283 // that never named you has no opinion about you.
284 $sentiment_total = array_sum($sentiment);
285 $positive_share = $sentiment_total > 0
286 ? self::rate($sentiment['positive'], $sentiment_total)
287 : 0.0;
288
289 // Components this run can honestly score on.
290 $measured = ['mention_rate', 'share_of_voice', 'citation_rate'];
291 if ($sentiment_total > 0) {
292 $measured[] = 'sentiment';
293 }
294
295 return [
296 'samples' => $samples,
297 'mention_rate' => $mention_rate,
298 'citation_rate' => $citation_rate,
299 'share_of_voice' => $share_of_voice,
300 'sentiment' => [
301 'positive' => $sentiment['positive'],
302 'neutral' => $sentiment['neutral'],
303 'negative' => $sentiment['negative'],
304 'positive_share' => $positive_share,
305 ],
306 // Sentiment is only part of the score when it was actually
307 // measured. Free plans never run the sentiment probe, and an
308 // answer that never named the brand holds no opinion of it —
309 // scoring an unmeasured component as 0 capped those runs at 85
310 // no matter how visible the brand was.
311 'measured' => $measured,
312 'visibility_index' => self::visibility_index([
313 'mention_rate' => $mention_rate,
314 'share_of_voice' => $share_of_voice,
315 'citation_rate' => $citation_rate,
316 'sentiment' => $positive_share,
317 ], $measured),
318 'by_platform' => array_values(array_map(
319 static function (array $row): array {
320 $row['mention_rate'] = self::rate($row['mentions'], $row['samples']);
321 $row['citation_rate'] = self::rate($row['citations'], $row['samples']);
322 return $row;
323 },
324 $by_platform
325 )),
326 'by_query' => array_values(array_map(
327 static function (array $row): array {
328 $row['mention_rate'] = self::rate($row['mentions'], $row['samples']);
329 $row['cited'] = $row['citations'] > 0;
330 $row['best_platform'] = self::best_platform($row['platforms']);
331 unset($row['platforms']);
332 return $row;
333 },
334 $by_query
335 )),
336 'by_type' => self::by_type($by_query),
337 'competitors' => self::leaderboard($mentions, $competitor_hits),
338 ];
339 }
340
341 /**
342 * Weighted 0–100 composite.
343 *
344 * @param array $parts Rates in 0..1 keyed like self::WEIGHTS.
345 * @param array|null $measured Component keys this run could measure; null
346 * means all of them.
347 * @return int Index, 0–100.
348 */
349 public static function visibility_index(array $parts, ?array $measured = null): int {
350 // Weights are renormalised over the components that were actually
351 // measured, so a run that couldn't measure one of them is scored out
352 // of what it could measure rather than penalised for the gap.
353 $weights = null === $measured
354 ? self::WEIGHTS
355 : array_intersect_key(self::WEIGHTS, array_flip($measured));
356
357 $total = array_sum($weights);
358 if ($total <= 0) {
359 return 0;
360 }
361
362 $score = 0.0;
363 foreach ($weights as $key => $weight) {
364 $score += ((float) ($parts[$key] ?? 0)) * ($weight / $total);
365 }
366
367 return (int) round(max(0.0, min(1.0, $score)) * 100);
368 }
369
370 /**
371 * Share-of-voice leaderboard: you plus every configured competitor,
372 * sorted by share so the UI can render it directly.
373 *
374 * @param int $own_mentions Your mention count.
375 * @param array $competitor_hits name => mentions.
376 * @return array<int, array{name: string, mentions: int, share: float, is_you: bool}>
377 */
378 private static function leaderboard(int $own_mentions, array $competitor_hits): array {
379 $total = $own_mentions + array_sum($competitor_hits);
380
381 $rows = [[
382 'name' => '', // filled by the caller/UI with "Your site"
383 'mentions' => $own_mentions,
384 'share' => $total > 0 ? self::rate($own_mentions, $total) : 0.0,
385 'is_you' => true,
386 ]];
387
388 foreach ($competitor_hits as $name => $hits) {
389 $rows[] = [
390 'name' => (string) $name,
391 'mentions' => (int) $hits,
392 'share' => $total > 0 ? self::rate((int) $hits, $total) : 0.0,
393 'is_you' => false,
394 ];
395 }
396
397 usort($rows, static fn(array $a, array $b): int => $b['mentions'] <=> $a['mentions']);
398
399 return $rows;
400 }
401
402 /**
403 * Mention rate per query type — feeds the radar chart.
404 *
405 * @param array $by_query Per-query rows.
406 * @return array<int, array{type: string, mention_rate: float, queries: int}>
407 */
408 private static function by_type(array $by_query): array {
409 $totals = [];
410
411 foreach ($by_query as $row) {
412 $type = (string) ($row['type'] ?? 'branded');
413 $totals[$type] ??= ['samples' => 0, 'mentions' => 0, 'queries' => 0];
414 $totals[$type]['samples'] += (int) $row['samples'];
415 $totals[$type]['mentions'] += (int) $row['mentions'];
416 $totals[$type]['queries']++;
417 }
418
419 $out = [];
420 foreach (self::QUERY_TYPES as $type) {
421 if (!isset($totals[$type])) {
422 continue;
423 }
424 $out[] = [
425 'type' => $type,
426 'mention_rate' => self::rate($totals[$type]['mentions'], $totals[$type]['samples']),
427 'queries' => $totals[$type]['queries'],
428 ];
429 }
430
431 return $out;
432 }
433
434 /**
435 * Platform with the highest mention rate for a query (ties → most samples).
436 *
437 * @param array $platforms platform => ['samples' => int, 'mentions' => int].
438 * @return string Platform slug, or '' when nothing was mentioned anywhere.
439 */
440 private static function best_platform(array $platforms): string {
441 $best = '';
442 $best_rate = 0.0;
443
444 foreach ($platforms as $platform => $counts) {
445 $rate = self::rate((int) $counts['mentions'], (int) $counts['samples']);
446 if ($rate > $best_rate) {
447 $best_rate = $rate;
448 $best = (string) $platform;
449 }
450 }
451
452 return $best;
453 }
454
455 /**
456 * Competitor names recorded on a task row (stored as JSON).
457 *
458 * @param array $task Task row.
459 * @return string[]
460 */
461 private static function task_competitors(array $task): array {
462 $raw = $task['competitors'] ?? [];
463
464 if (is_string($raw)) {
465 $decoded = json_decode($raw, true);
466 $raw = is_array($decoded) ? $decoded : [];
467 }
468
469 return is_array($raw) ? array_values(array_filter(array_map('strval', $raw))) : [];
470 }
471
472 /**
473 * Host without scheme, path or www.
474 *
475 * @param string $url URL or bare host.
476 * @return string
477 */
478 private static function bare_host(string $url): string {
479 $url = trim($url);
480 if ('' === $url) {
481 return '';
482 }
483
484 if (false !== strpos($url, '//')) {
485 $parsed = wp_parse_url($url, PHP_URL_HOST);
486 $url = is_string($parsed) ? $parsed : $url;
487 }
488
489 // Drop any path that survived (bare "example.com/shop").
490 $url = explode('/', $url)[0];
491
492 return strtolower(preg_replace('/^www\./i', '', $url));
493 }
494
495 /**
496 * Ratio rounded to 4dp, guarding division by zero.
497 *
498 * @param int $numerator Hits.
499 * @param int $denominator Total.
500 * @return float 0..1
501 */
502 private static function rate(int $numerator, int $denominator): float {
503 return $denominator > 0 ? round($numerator / $denominator, 4) : 0.0;
504 }
505
506 /**
507 * Zeroed results for a run with nothing usable, so the UI renders an empty
508 * dashboard rather than breaking on missing keys.
509 *
510 * @param array $competitors Configured competitors.
511 * @return array
512 */
513 private static function empty_results(array $competitors): array {
514 $hits = [];
515 foreach ($competitors as $competitor) {
516 $name = (string) ($competitor['name'] ?? '');
517 if ('' !== $name) {
518 $hits[$name] = 0;
519 }
520 }
521
522 return [
523 'samples' => 0,
524 'measured' => [],
525 'mention_rate' => 0.0,
526 'citation_rate' => 0.0,
527 'share_of_voice' => 0.0,
528 'sentiment' => ['positive' => 0, 'neutral' => 0, 'negative' => 0, 'positive_share' => 0.0],
529 'visibility_index' => 0,
530 'by_platform' => [],
531 'by_query' => [],
532 'by_type' => [],
533 'competitors' => self::leaderboard(0, $hits),
534 ];
535 }
536 }
537