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.10.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 All 51 releases
← All changes | includes/seo/class-email-report-data-provider.php +310 -19 1.28.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{
@@ -48,11 +174,20 @@
48 174 * }
49 175 */
50 176 public function fetch(int $frequency_days): array {
51 177 $frequency_days = max(1, $frequency_days);
52 - $today = current_time('Y-m-d');
53 - $period_end = $today;
54 - $period_start = wp_date('Y-m-d', strtotime("-{$frequency_days} days", strtotime($today)));
178 +
179 + // One canonical window for the whole report. Search Console lags
180 + // ~2 days, so it ends on the last date that actually has data —
181 + // the same anchor Analytics_Manager::get_dashboard_data() uses for
182 + // Key Metrics and Position Summary. Previously the header label ran
183 + // through today and the comparison through yesterday, so a reader
184 + // was handed three windows and told they were one.
185 + $period_end = gmdate('Y-m-d', strtotime('-2 days'));
186 + $period_start = gmdate(
187 + 'Y-m-d',
188 + strtotime('-' . ($frequency_days - 1) . ' days', strtotime($period_end))
189 + );
55 190 $period_label = $this->format_period_label($period_start, $period_end);
56 191
57 192 $manager = $this->get_analytics_manager();
58 193 if ($manager === null) {
@@ -74,14 +209,16 @@
74 209 // Real period-over-period comparison: pull query- and page-level
75 210 // metrics for the current window AND the immediately preceding
76 211 // window of equal length straight from Search Console, then key
77 212 // them so winning/losing sections can compute true deltas.
78 - $comparison = $this->build_comparison($manager, $frequency_days);
213 + $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
79 214
80 215 return [
81 216 'available' => true,
217 + 'readiness' => $this->readiness(),
82 218 'current' => is_array($current) ? $current : [],
83 219 'comparison' => $comparison,
220 + 'ai' => $this->ai_summary($frequency_days),
84 221 'period_start' => $period_start,
85 222 'period_end' => $period_end,
86 223 'period_label' => $period_label,
87 224 ];
@@ -103,18 +240,23 @@
103 240 *
104 241 * Uses the Search Console client's arbitrary date-range API
105 242 * (`get_search_performance_by_dates`) — the same one the Rank Tracker
106 243 * and the Pro winning/losing endpoint use — to fetch query- and
107 - * page-level rows for two equal, adjacent windows. GSC lags ~2 days, so
108 - * the current window ends yesterday and the previous window is the N
109 - * days before it.
244 + * page-level rows for two equal, adjacent windows.
110 245 *
246 + * The current window is handed in by fetch() rather than recomputed
247 + * here, so the deltas describe exactly the period the report's header
248 + * advertises. The previous window is the same length, immediately
249 + * before it, with no gap or overlap.
250 + *
111 251 * @param object $manager Analytics_Manager instance.
112 252 * @param int $frequency_days Window length in days.
253 + * @param string $cur_start Current window start (Y-m-d).
254 + * @param string $cur_end Current window end (Y-m-d).
113 255 * @return array{available:bool,queries:array,pages:array}
114 256 */
115 - private function build_comparison($manager, int $frequency_days): array {
116 - $empty = ['available' => false, 'queries' => [], 'pages' => []];
257 + private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
258 + $empty = ['available' => false, 'queries' => [], 'pages' => [], 'totals' => []];
117 259
118 260 if (!method_exists($manager, 'get_search_console_client')) {
119 261 return $empty;
120 262 }
@@ -126,12 +268,12 @@
126 268 if ($site_url === '') {
127 269 return $empty;
128 270 }
129 271
130 - $cur_end = gmdate('Y-m-d', strtotime('-1 day'));
131 - $cur_start = gmdate('Y-m-d', strtotime('-' . $frequency_days . ' days'));
132 - $prev_end = gmdate('Y-m-d', strtotime('-' . ($frequency_days + 1) . ' days'));
133 - $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days * 2) . ' days'));
272 + // Previous window: the same number of days, ending the day before
273 + // the current window opens.
274 + $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($cur_start)));
275 + $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days - 1) . ' days', strtotime($prev_end)));
134 276
135 277 $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['query']);
136 278 $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['query']);
137 279 $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['page']);
@@ -136,19 +278,110 @@
136 278 $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['query']);
137 279 $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['page']);
138 280 $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['page']);
139 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 +
140 289 return [
141 290 'available' => true,
142 291 'queries' => $this->merge_periods($cur_q, $prev_q, true),
143 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 + ],
144 297 ];
145 298 }
146 299
147 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 + /**
148 375 * Merge current + previous GSC rows into one keyed map carrying both
149 376 * periods' clicks and (for queries) average position.
150 377 *
378 + * The key set is the union of both windows. Search Console omits rows
379 + * with no activity in a window, so a page or query that dropped to zero
380 + * clicks has no current row at all — keying off `$current` alone would
381 + * silently discard exactly the biggest losers the losing sections exist
382 + * to surface.
383 + *
151 384 * @param array $current Current-window rows.
152 385 * @param array $previous Previous-window rows.
153 386 * @param bool $is_query True for query rows, false for page rows.
154 387 * @return array<string,array>
@@ -185,8 +418,34 @@
185 418 }
186 419 $merged[$key] = $entry;
187 420 }
188 421
422 + // Total drop-outs: present last period, absent now. Synthesize them
423 + // from the previous window with the current metrics zeroed. Position
424 + // stays null rather than 0 — "no data" is not "ranked first".
425 + foreach ($prev_map as $key => $prev_row) {
426 + if (isset($merged[$key])) {
427 + continue;
428 + }
429 + $raw = (string) ($prev_row['keys'][0] ?? '');
430 + if ($raw === '') {
431 + continue;
432 + }
433 +
434 + $entry = [
435 + 'cur_clicks' => 0,
436 + 'prev_clicks' => (int) ($prev_row['clicks'] ?? 0),
437 + ];
438 + if ($is_query) {
439 + $entry['query'] = $raw;
440 + $entry['cur_pos'] = null;
441 + $entry['prev_pos'] = round((float) ($prev_row['position'] ?? 0), 1);
442 + } else {
443 + $entry['url'] = $raw;
444 + }
445 + $merged[$key] = $entry;
446 + }
447 +
189 448 return $merged;
190 449 }
191 450
192 451 private function normalize_key(string $key, bool $is_query): string {
@@ -192,8 +451,29 @@
192 451 private function normalize_key(string $key, bool $is_query): string {
193 452 return $is_query ? trim(strtolower($key)) : $key;
194 453 }
195 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 + };
474 + }
475 +
196 476 private function get_analytics_manager() {
197 477 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';
198 478 if (!class_exists($cls)) {
199 479 return null;
@@ -206,9 +486,20 @@
206 486 }
207 487
208 488 private function format_period_label(string $start, string $end): string {
209 489 $fmt = (string) get_option('date_format', 'M j, Y');
210 - $a = wp_date($fmt, strtotime($start) ?: time());
211 - $b = wp_date($fmt, strtotime($end) ?: time());
212 - return sprintf('%s – %s', $a, $b);
490 + return sprintf('%s – %s', $this->format_day($start, $fmt), $this->format_day($end, $fmt));
491 + }
492 +
493 + /**
494 + * Render a bare Y-m-d as a localized date.
495 + *
496 + * Anchored at midday UTC on purpose: wp_date() shifts the timestamp into
497 + * the site timezone, and a date parsed at midnight would render as the
498 + * day before on any negative offset. Midday leaves the calendar date
499 + * intact across every real-world offset.
500 + */
501 + private function format_day(string $date, string $format): string {
502 + $ts = strtotime($date . ' 12:00:00 UTC');
503 + return wp_date($format, $ts ?: time());
213 504 }
214 505 }