PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.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 1.1.0 All 49 releases
thinkrank / includes / seo / email-report-sections / class-ai-search-section.php

class-ai-search-section.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.8.0, at includes/seo/email-report-sections/class-ai-search-section.php

180 lines 6.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AI Search Section — visits referred by AI assistants.
4 *
5 * Reads the first-party tracker AI Insights already runs: referral sessions
6 * from ChatGPT, Perplexity, Gemini, Claude, Copilot and the rest, their share
7 * of all sessions, the top sources, crawler fetches and whether llms.txt is
8 * live. Rendered only when the tracker has recorded something (#742).
9 *
10 * @package ThinkRank
11 * @subpackage SEO\Email_Report_Sections
12 * @since 2.8.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\SEO\Email_Report_Sections;
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 final class Ai_Search_Section implements Email_Report_Section_Interface {
24
25 private const PLATFORM_LABELS = [
26 'chatgpt' => 'ChatGPT',
27 'perplexity' => 'Perplexity',
28 'gemini' => 'Gemini',
29 'claude' => 'Claude',
30 'copilot' => 'Copilot',
31 'meta-ai' => 'Meta AI',
32 'you' => 'You.com',
33 'poe' => 'Poe',
34 'grok' => 'Grok',
35 'mistral' => 'Mistral',
36 'deepseek' => 'DeepSeek',
37 'kimi' => 'Kimi',
38 ];
39
40 public function key(): string {
41 return 'ai_search';
42 }
43
44 public function label(): string {
45 return __('Traffic from AI assistants', 'thinkrank');
46 }
47
48 public function default_enabled(): bool {
49 return true;
50 }
51
52 public function requires_capability(): ?string {
53 return null;
54 }
55
56 public function renders_own_heading(): bool {
57 return true;
58 }
59
60 public function collect(array $context): array {
61 $shared = $context['shared'] ?? [];
62 $ai = $shared['ai'] ?? null;
63 if (!is_array($ai) || !is_array($ai['current'] ?? null)) {
64 return [];
65 }
66 $current = $ai['current'];
67 $previous = is_array($ai['previous'] ?? null) ? $ai['previous'] : [];
68
69 $prev_sessions = (int) ($previous['ai_sessions'] ?? 0);
70 $prev_baseline = (int) ($previous['baseline'] ?? 0);
71
72 return [
73 'ai_sessions' => (int) ($current['ai_sessions'] ?? 0),
74 'baseline' => (int) ($current['baseline'] ?? 0),
75 'ai_share' => (float) ($current['ai_share'] ?? 0.0),
76 'prev_sessions' => $prev_baseline > 0 || $prev_sessions > 0 ? $prev_sessions : null,
77 'prev_share' => $prev_baseline > 0 ? round($prev_sessions / $prev_baseline * 100, 1) : null,
78 'platforms' => is_array($current['platforms'] ?? null) ? $current['platforms'] : [],
79 'crawler_hits' => (int) array_sum(array_map('intval', is_array($current['crawlers'] ?? null) ? $current['crawlers'] : [])),
80 'llms_txt' => !empty($current['llms_txt']),
81 ];
82 }
83
84 public function has_data(array $payload): bool {
85 return (int) ($payload['ai_sessions'] ?? 0) > 0 || (int) ($payload['crawler_hits'] ?? 0) > 0;
86 }
87
88 public function render(array $payload): string {
89 if (!$this->has_data($payload)) {
90 return '';
91 }
92
93 $sessions_change = '';
94 if ($payload['prev_sessions'] !== null) {
95 $c = Email_Report_Html::pct_change((float) $payload['ai_sessions'], (float) $payload['prev_sessions']);
96 $sessions_change = $c ? Email_Report_Html::change($c['text'], $c['direction'], $c['direction'] !== 'down') : '';
97 }
98 $share_change = '';
99 if ($payload['prev_share'] !== null) {
100 $pts = round((float) $payload['ai_share'] - (float) $payload['prev_share'], 1);
101 if (abs($pts) >= 0.1) {
102 $share_change = Email_Report_Html::change(number_format_i18n(abs($pts), 1), $pts > 0 ? 'up' : 'down', $pts > 0, __('pts', 'thinkrank'));
103 }
104 }
105
106 $tiles = [
107 Email_Report_Html::stat([
108 'label' => __('AI referral sessions', 'thinkrank'),
109 'value' => number_format_i18n((int) $payload['ai_sessions']),
110 'change' => $sessions_change,
111 'icon' => 'ai',
112 ]),
113 Email_Report_Html::stat([
114 'label' => __('Share of all sessions', 'thinkrank'),
115 'value' => number_format_i18n((float) $payload['ai_share'], 1) . '%',
116 'change' => $share_change,
117 'icon' => 'share',
118 ]),
119 ];
120
121 $lines = [];
122 $sources = $this->source_line((array) $payload['platforms'], (int) $payload['ai_sessions']);
123 if ($sources !== '') {
124 $lines[] = __('Top sources:', 'thinkrank') . ' ' . $sources;
125 }
126 if ((int) $payload['crawler_hits'] > 0) {
127 $lines[] = sprintf(
128 /* translators: %s: number of page fetches by AI crawlers. */
129 _n('AI crawlers fetched %s page this period.', 'AI crawlers fetched %s pages this period.', (int) $payload['crawler_hits'], 'thinkrank'),
130 '<strong>' . esc_html(number_format_i18n((int) $payload['crawler_hits'])) . '</strong>'
131 );
132 }
133 $lines[] = !empty($payload['llms_txt'])
134 ? __('Your llms.txt is published.', 'thinkrank')
135 : __('Your llms.txt is not published yet, so AI crawlers get no guide to your site.', 'thinkrank');
136
137 $detail = '<div style="' . Email_Report_Html::FONT . 'font-size:13px;color:' . Email_Report_Html::INK . ';margin-top:6px;line-height:1.7;">'
138 . implode('<br>', $lines) . '</div>';
139
140 return Email_Report_Html::heading(
141 $this->label(),
142 __('Visits referred by ChatGPT, Perplexity, Gemini, Claude, Copilot and other assistants', 'thinkrank')
143 )
144 . Email_Report_Html::stat_grid($tiles)
145 . $detail
146 . Email_Report_Html::link(__('Open AI Insights', 'thinkrank'), Email_Report_Html::admin_link('ai-insights', 'ai-insights-overview'));
147 }
148
149 public function fallback_html(): string {
150 return '';
151 }
152
153 /**
154 * "ChatGPT 62% · Perplexity 21% · Other 6%" — top three platforms by
155 * share, the rest folded into Other. Already-escaped HTML.
156 */
157 private function source_line(array $platforms, int $total): string {
158 if ($total <= 0 || $platforms === []) {
159 return '';
160 }
161 arsort($platforms);
162 $parts = [];
163 $shown = 0;
164 $i = 0;
165 foreach ($platforms as $slug => $hits) {
166 if ($i++ >= 3) {
167 break;
168 }
169 $shown += (int) $hits;
170 $label = self::PLATFORM_LABELS[(string) $slug] ?? ucfirst((string) $slug);
171 $parts[] = '<strong>' . esc_html($label) . '</strong> ' . (int) round((int) $hits / $total * 100) . '%';
172 }
173 $rest = $total - $shown;
174 if ($rest > 0) {
175 $parts[] = esc_html__('Other', 'thinkrank') . ' ' . (int) round($rest / $total * 100) . '%';
176 }
177 return implode(' · ', $parts);
178 }
179 }
180