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 / class-email-report-data-provider.php

class-email-report-data-provider.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/class-email-report-data-provider.php

506 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Data Provider
4 *
5 * Pulls dashboard data for the current period and the immediately
6 * preceding period of equal length, then hands both to sections so they
7 * can diff and rank. Sections never call Analytics_Manager directly —
8 * one fetch per period, shared across the report.
9 *
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.
15 *
16 * If Analytics_Manager isn't available the provider returns an
17 * `available => false` result and sections collect nothing.
18 *
19 * @package ThinkRank
20 * @subpackage SEO
21 * @since 1.9.0
22 */
23
24 declare(strict_types=1);
25
26 namespace ThinkRank\SEO;
27
28 use Throwable;
29
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 /**
35 * Email_Report_Data_Provider
36 *
37 * @since 1.9.0
38 */
39 final class Email_Report_Data_Provider {
40
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 /**
163 * Pull current + prior period dashboard data.
164 *
165 * @param int $frequency_days Reporting frequency in days.
166 * @return array{
167 * available:bool,
168 * current:array,
169 * prior:array,
170 * period_start:string,
171 * period_end:string,
172 * period_label:string,
173 * error?:string
174 * }
175 */
176 public function fetch(int $frequency_days): array {
177 $frequency_days = max(1, $frequency_days);
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 );
190 $period_label = $this->format_period_label($period_start, $period_end);
191
192 $manager = $this->get_analytics_manager();
193 if ($manager === null) {
194 return [
195 'available' => false,
196 'current' => [],
197 'prior' => [],
198 'period_start' => $period_start,
199 'period_end' => $period_end,
200 'period_label' => $period_label,
201 'error' => __('Analytics integration not available.', 'thinkrank'),
202 ];
203 }
204
205 try {
206 $range = $frequency_days . 'd';
207 $current = $manager->get_dashboard_data($range);
208
209 // Real period-over-period comparison: pull query- and page-level
210 // metrics for the current window AND the immediately preceding
211 // window of equal length straight from Search Console, then key
212 // them so winning/losing sections can compute true deltas.
213 $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
214
215 return [
216 'available' => true,
217 'readiness' => $this->readiness(),
218 'current' => is_array($current) ? $current : [],
219 'comparison' => $comparison,
220 'ai' => $this->ai_summary($frequency_days),
221 'period_start' => $period_start,
222 'period_end' => $period_end,
223 'period_label' => $period_label,
224 ];
225 } catch (Throwable $e) {
226 return [
227 'available' => false,
228 'current' => [],
229 'comparison' => ['available' => false, 'queries' => [], 'pages' => []],
230 'period_start' => $period_start,
231 'period_end' => $period_end,
232 'period_label' => $period_label,
233 'error' => $e->getMessage(),
234 ];
235 }
236 }
237
238 /**
239 * Build the current-vs-previous comparison from Search Console.
240 *
241 * Uses the Search Console client's arbitrary date-range API
242 * (`get_search_performance_by_dates`) — the same one the Rank Tracker
243 * and the Pro winning/losing endpoint use — to fetch query- and
244 * page-level rows for two equal, adjacent windows.
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 *
251 * @param object $manager Analytics_Manager instance.
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).
255 * @return array{available:bool,queries:array,pages:array}
256 */
257 private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
258 $empty = ['available' => false, 'queries' => [], 'pages' => [], 'totals' => []];
259
260 if (!method_exists($manager, 'get_search_console_client')) {
261 return $empty;
262 }
263 $sc = $manager->get_search_console_client();
264 if (!$sc || !method_exists($sc, 'get_search_performance_by_dates')) {
265 return $empty;
266 }
267 $site_url = method_exists($manager, 'get_property_url') ? (string) $manager->get_property_url() : '';
268 if ($site_url === '') {
269 return $empty;
270 }
271
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)));
276
277 $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['query']);
278 $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['query']);
279 $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['page']);
280 $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['page']);
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
289 return [
290 'available' => true,
291 'queries' => $this->merge_periods($cur_q, $prev_q, true),
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 ],
297 ];
298 }
299
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 /**
375 * Merge current + previous GSC rows into one keyed map carrying both
376 * periods' clicks and (for queries) average position.
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 *
384 * @param array $current Current-window rows.
385 * @param array $previous Previous-window rows.
386 * @param bool $is_query True for query rows, false for page rows.
387 * @return array<string,array>
388 */
389 private function merge_periods(array $current, array $previous, bool $is_query): array {
390 $prev_map = [];
391 foreach ($previous as $row) {
392 $key = (string) ($row['keys'][0] ?? '');
393 if ($key === '') {
394 continue;
395 }
396 $prev_map[$this->normalize_key($key, $is_query)] = $row;
397 }
398
399 $merged = [];
400 foreach ($current as $row) {
401 $raw = (string) ($row['keys'][0] ?? '');
402 if ($raw === '') {
403 continue;
404 }
405 $key = $this->normalize_key($raw, $is_query);
406 $prev = $prev_map[$key] ?? null;
407
408 $entry = [
409 'cur_clicks' => (int) ($row['clicks'] ?? 0),
410 'prev_clicks' => $prev ? (int) ($prev['clicks'] ?? 0) : 0,
411 ];
412 if ($is_query) {
413 $entry['query'] = $raw;
414 $entry['cur_pos'] = round((float) ($row['position'] ?? 0), 1);
415 $entry['prev_pos'] = $prev ? round((float) ($prev['position'] ?? 0), 1) : null;
416 } else {
417 $entry['url'] = $raw;
418 }
419 $merged[$key] = $entry;
420 }
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
448 return $merged;
449 }
450
451 private function normalize_key(string $key, bool $is_query): string {
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 };
474 }
475
476 private function get_analytics_manager() {
477 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';
478 if (!class_exists($cls)) {
479 return null;
480 }
481 try {
482 return new $cls();
483 } catch (Throwable $e) {
484 return null;
485 }
486 }
487
488 private function format_period_label(string $start, string $end): string {
489 $fmt = (string) get_option('date_format', 'M j, Y');
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());
504 }
505 }
506