PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.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 All 50 releases
← All changes | includes/seo/class-email-report-data-provider.php +238 -4 2.3.0 → 2.9.0 View file →
@@ -6,12 +6,17 @@
6 6 * preceding period of equal length, then hands both to sections so they
7 7 * can diff and rank. Sections never call Analytics_Manager directly —
8 8 * one fetch per period, shared across the report.
9 9 *
10 - * If Analytics_Manager isn't available (no Google connection, etc.) the
11 - * provider returns an `available => false` result; sections then render
12 - * their fallback HTML per PRD's graceful-degradation requirement.
10 + * readiness() answers, before any data is pulled, whether there is a report
11 + * to build: Search Console is required, Google Analytics 4 and the AI
12 + * traffic tracker each add a card when present (#742). The generator asks
13 + * this first so a disconnected site is paused with a reason rather than
14 + * fetched, rendered and sent as a column of blanks.
13 15 *
16 + * If Analytics_Manager isn't available the provider returns an
17 + * `available => false` result and sections collect nothing.
18 + *
14 19 * @package ThinkRank
15 20 * @subpackage SEO
16 21 * @since 1.9.0
17 22 */
@@ -33,8 +38,129 @@
33 38 */
34 39 final class Email_Report_Data_Provider {
35 40
36 41 /**
42 + * Which data sources the report can draw on right now.
43 + *
44 + * Cheap on purpose — no dashboard fetch, no Search Console query. It
45 + * runs on every hourly tick while a site is paused and on every load of
46 + * the Email Reporting panel.
47 + *
48 + * @return array{
49 + * ready:bool,
50 + * search_console:bool,
51 + * analytics:bool,
52 + * ai_traffic:bool,
53 + * reason:?string
54 + * }
55 + */
56 + public function readiness(): array {
57 + // Credentials, not client objects. Analytics_Manager constructs a
58 + // Search Console client whether or not a token exists, so "is there
59 + // a client?" is always yes. The predicates below are the ones
60 + // get-integrations-status reports, so the panel, the ability and
61 + // the report can never disagree about the same site.
62 + $settings = $this->settings();
63 +
64 + $oauth_present = '' !== (string) $settings->get('google_access_token', '');
65 + $search_console = $oauth_present
66 + || '' !== (string) $settings->get('google_search_console_api_key', '');
67 +
68 + // GA4: the Data API client is built only when a token AND a selected
69 + // property both exist — mirror that exactly.
70 + $ga_property = (string) $settings->get('seo_analytics_google_analytics_property_id', '');
71 + $analytics = ($oauth_present && '' !== $ga_property)
72 + || '' !== (string) $settings->get('google_analytics_api_key', '');
73 +
74 + $readiness = [
75 + 'ready' => $search_console,
76 + 'search_console' => $search_console,
77 + 'analytics' => $analytics,
78 + 'ai_traffic' => $this->ai_tracker_has_data(),
79 + 'reason' => $search_console ? null : 'search_console_not_connected',
80 + ];
81 +
82 + /**
83 + * Filter the report's readiness.
84 + *
85 + * Lets a host that feeds the report from somewhere other than the
86 + * Google integrations declare itself ready, and lets tests force
87 + * either state.
88 + *
89 + * @since 2.8.0
90 + *
91 + * @param array $readiness See readiness().
92 + */
93 + $filtered = apply_filters('thinkrank_email_report_readiness', $readiness);
94 +
95 + return is_array($filtered) ? array_merge($readiness, $filtered) : $readiness;
96 + }
97 +
98 + /**
99 + * Subject-line tokens for a fetched report (#742).
100 + *
101 + * `%headline%` is the sentence the default subject is built from:
102 + * "12,480 Google clicks (+12.4%) in the last 30 days" when both windows
103 + * have totals, "Your SEO report for Aug 19 – Sep 17" when there is
104 + * nothing to compare. The parts are exposed as their own tokens so a
105 + * custom subject template can rebuild it differently.
106 + *
107 + * @param array $shared Output of fetch().
108 + * @param int $frequency_days Report window in days.
109 + * @return array<string,string> Token => value.
110 + */
111 + public static function subject_tokens(array $shared, int $frequency_days): array {
112 + $days = max(1, $frequency_days);
113 + $label = (string) ($shared['period_label'] ?? '');
114 + $totals = $shared['comparison']['totals'] ?? [];
115 + $current = is_array($totals['current'] ?? null) ? $totals['current'] : [];
116 + $previous = is_array($totals['previous'] ?? null) ? $totals['previous'] : [];
117 +
118 + $clicks = (int) ($current['clicks'] ?? 0);
119 + $impressions = (int) ($current['impressions'] ?? 0);
120 + if ($clicks === 0 && $impressions === 0) {
121 + $dash = $shared['current']['search_performance']['totals'] ?? [];
122 + $clicks = (int) ($dash['clicks'] ?? 0);
123 + $impressions = (int) ($dash['impressions'] ?? 0);
124 + }
125 +
126 + $change = null;
127 + if ((int) ($previous['clicks'] ?? 0) > 0 && class_exists(Email_Report_Sections\Email_Report_Html::class)) {
128 + $change = Email_Report_Sections\Email_Report_Html::pct_change((float) $clicks, (float) ($previous['clicks'] ?? 0));
129 + }
130 + $signed = $change !== null && $change['direction'] !== 'flat'
131 + ? ($change['direction'] === 'up' ? '+' : '−') . $change['text']
132 + : '';
133 +
134 + if ($clicks > 0 || $impressions > 0) {
135 + $headline = sprintf(
136 + /* translators: 1: number of clicks, 2: change in parentheses or empty, 3: number of days. */
137 + _n('%1$s Google clicks%2$s in the last %3$d day', '%1$s Google clicks%2$s in the last %3$d days', $days, 'thinkrank'),
138 + number_format_i18n($clicks),
139 + $signed !== '' ? ' (' . $signed . ')' : '',
140 + $days
141 + );
142 + } else {
143 + $headline = $label !== ''
144 + ? sprintf(
145 + /* translators: %s: period label, e.g. "Aug 19 – Sep 17, 2026". */
146 + __('Your SEO report for %s', 'thinkrank'),
147 + $label
148 + )
149 + : __('Your SEO report', 'thinkrank');
150 + }
151 +
152 + return [
153 + '%period%' => $label,
154 + '%period_days%' => (string) $days,
155 + '%clicks%' => number_format_i18n($clicks),
156 + '%clicks_change%' => $signed,
157 + '%impressions%' => number_format_i18n($impressions),
158 + '%headline%' => $headline,
159 + ];
160 + }
161 +
162 + /**
37 163 * Pull current + prior period dashboard data.
38 164 *
39 165 * @param int $frequency_days Reporting frequency in days.
40 166 * @return array{
@@ -87,10 +213,12 @@
87 213 $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
88 214
89 215 return [
90 216 'available' => true,
217 + 'readiness' => $this->readiness(),
91 218 'current' => is_array($current) ? $current : [],
92 219 'comparison' => $comparison,
220 + 'ai' => $this->ai_summary($frequency_days),
93 221 'period_start' => $period_start,
94 222 'period_end' => $period_end,
95 223 'period_label' => $period_label,
96 224 ];
@@ -126,9 +254,9 @@
126 254 * @param string $cur_end Current window end (Y-m-d).
127 255 * @return array{available:bool,queries:array,pages:array}
128 256 */
129 257 private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
130 - $empty = ['available' => false, 'queries' => [], 'pages' => []];
258 + $empty = ['available' => false, 'queries' => [], 'pages' => [], 'totals' => []];
131 259
132 260 if (!method_exists($manager, 'get_search_console_client')) {
133 261 return $empty;
134 262 }
@@ -150,16 +278,101 @@
150 278 $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['query']);
151 279 $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['page']);
152 280 $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['page']);
153 281
282 + // Whole-property totals for both windows. A query with no
283 + // dimensions returns one aggregated row, so the hero's clicks,
284 + // impressions, CTR and position — and their change — are exact
285 + // rather than summed from the 1,000-row query lists above.
286 + $cur_t = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1, []);
287 + $prev_t = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1, []);
288 +
154 289 return [
155 290 'available' => true,
156 291 'queries' => $this->merge_periods($cur_q, $prev_q, true),
157 292 'pages' => $this->merge_periods($cur_p, $prev_p, false),
293 + 'totals' => [
294 + 'current' => $this->totals_row($cur_t),
295 + 'previous' => $this->totals_row($prev_t),
296 + ],
158 297 ];
159 298 }
160 299
161 300 /**
301 + * Normalise the single aggregate row Search Console returns for a
302 + * dimensionless query. An empty result (a property with no traffic in
303 + * the window) yields zeroes, which the hero treats as "no comparison".
304 + *
305 + * @param array $rows API rows.
306 + * @return array{clicks:int,impressions:int,ctr:float,position:float}
307 + */
308 + private function totals_row(array $rows): array {
309 + $row = is_array($rows[0] ?? null) ? $rows[0] : [];
310 + return [
311 + 'clicks' => (int) ($row['clicks'] ?? 0),
312 + 'impressions' => (int) ($row['impressions'] ?? 0),
313 + 'ctr' => (float) ($row['ctr'] ?? 0.0),
314 + 'position' => (float) ($row['position'] ?? 0.0),
315 + ];
316 + }
317 +
318 + /**
319 + * AI-assistant traffic for the current window and the one before it,
320 + * from the first-party tracker AI Insights already runs. Null when the
321 + * tracker is absent or has recorded nothing.
322 + *
323 + * The tracker summarises a trailing window, so the previous period is
324 + * the double window minus the current one.
325 + *
326 + * @return array{current:array,previous:array}|null
327 + */
328 + private function ai_summary(int $frequency_days): ?array {
329 + $tracker = $this->get_ai_tracker();
330 + if ($tracker === null) {
331 + return null;
332 + }
333 + try {
334 + $current = (array) $tracker->summary($frequency_days);
335 + if ((int) ($current['ai_sessions'] ?? 0) === 0 && (int) ($current['baseline'] ?? 0) === 0) {
336 + return null;
337 + }
338 + $double = (array) $tracker->summary($frequency_days * 2);
339 + $previous = [
340 + 'ai_sessions' => max(0, (int) ($double['ai_sessions'] ?? 0) - (int) ($current['ai_sessions'] ?? 0)),
341 + 'baseline' => max(0, (int) ($double['baseline'] ?? 0) - (int) ($current['baseline'] ?? 0)),
342 + ];
343 + return ['current' => $current, 'previous' => $previous];
344 + } catch (Throwable $e) {
345 + return null;
346 + }
347 + }
348 +
349 + private function ai_tracker_has_data(): bool {
350 + $tracker = $this->get_ai_tracker();
351 + if ($tracker === null) {
352 + return false;
353 + }
354 + try {
355 + $summary = (array) $tracker->summary(30);
356 + return (int) ($summary['ai_sessions'] ?? 0) > 0 || (int) ($summary['baseline'] ?? 0) > 0;
357 + } catch (Throwable $e) {
358 + return false;
359 + }
360 + }
361 +
362 + private function get_ai_tracker() {
363 + $cls = '\\ThinkRank\\SEO\\Ai_Traffic_Tracker';
364 + if (!class_exists($cls) || !method_exists($cls, 'summary')) {
365 + return null;
366 + }
367 + try {
368 + return new $cls();
369 + } catch (Throwable $e) {
370 + return null;
371 + }
372 + }
373 +
374 + /**
162 375 * Merge current + previous GSC rows into one keyed map carrying both
163 376 * periods' clicks and (for queries) average position.
164 377 *
165 378 * The key set is the union of both windows. Search Console omits rows
@@ -236,8 +449,29 @@
236 449 }
237 450
238 451 private function normalize_key(string $key, bool $is_query): string {
239 452 return $is_query ? trim(strtolower($key)) : $key;
453 + }
454 +
455 + /**
456 + * The plugin settings store, or a null-object when it is not loaded
457 + * (unit tests without the core classes), which reads as "nothing
458 + * configured".
459 + */
460 + private function settings() {
461 + $cls = '\\ThinkRank\\Core\\Settings';
462 + if (class_exists($cls) && method_exists($cls, 'instance')) {
463 + try {
464 + return $cls::instance();
465 + } catch (Throwable $e) {
466 + // Fall through to the null object.
467 + }
468 + }
469 + return new class() {
470 + public function get(string $key, $fallback = null) {
471 + return $fallback;
472 + }
473 + };
240 474 }
241 475
242 476 private function get_analytics_manager() {
243 477 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';