PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 57 releases
← All changes | includes/seo/class-email-report-data-provider.php +430 -14 2.5.0 → 2.14.1 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,153 @@
33 38 */
34 39 final class Email_Report_Data_Provider {
35 40
36 41 /**
42 + * Rows fetched per window for the query and page comparisons.
43 + *
44 + * Search Console orders rows by clicks and stops here, so a list that
45 + * comes back this long may be truncated: a row missing from it may sit
46 + * just below the cut rather than have no traffic (#906).
47 + */
48 + private const COMPARISON_ROW_LIMIT = 1000;
49 +
50 + /**
51 + * How many apparent drop-outs are confirmed against the current window.
52 + *
53 + * The losing cards show five rows, so the biggest previous-period
54 + * candidates are all that can reach them. Anything past this cap is left
55 + * out of the comparison rather than presumed lost.
56 + */
57 + private const DROPOUT_CONFIRM_LIMIT = 25;
58 +
59 + /**
60 + * Longest regular expression sent in one confirmation request. Search
61 + * Console rejects longer expressions, so candidates are batched under it.
62 + */
63 + private const DROPOUT_REGEX_MAX_LENGTH = 3500;
64 +
65 + /**
66 + * Which data sources the report can draw on right now.
67 + *
68 + * Cheap on purpose — no dashboard fetch, no Search Console query. It
69 + * runs on every hourly tick while a site is paused and on every load of
70 + * the Email Reporting panel.
71 + *
72 + * @return array{
73 + * ready:bool,
74 + * search_console:bool,
75 + * analytics:bool,
76 + * ai_traffic:bool,
77 + * reason:?string
78 + * }
79 + */
80 + public function readiness(): array {
81 + // Credentials, not client objects. Analytics_Manager constructs a
82 + // Search Console client whether or not a token exists, so "is there
83 + // a client?" is always yes. The predicates below are the ones
84 + // get-integrations-status reports, so the panel, the ability and
85 + // the report can never disagree about the same site.
86 + $settings = $this->settings();
87 +
88 + $oauth_present = '' !== (string) $settings->get('google_access_token', '');
89 + $search_console = $oauth_present
90 + || '' !== (string) $settings->get('google_search_console_api_key', '');
91 +
92 + // GA4: the Data API client is built only when a token AND a selected
93 + // property both exist — mirror that exactly.
94 + $ga_property = (string) $settings->get('seo_analytics_google_analytics_property_id', '');
95 + $analytics = ($oauth_present && '' !== $ga_property)
96 + || '' !== (string) $settings->get('google_analytics_api_key', '');
97 +
98 + $readiness = [
99 + 'ready' => $search_console,
100 + 'search_console' => $search_console,
101 + 'analytics' => $analytics,
102 + 'ai_traffic' => $this->ai_tracker_has_data(),
103 + 'reason' => $search_console ? null : 'search_console_not_connected',
104 + ];
105 +
106 + /**
107 + * Filter the report's readiness.
108 + *
109 + * Lets a host that feeds the report from somewhere other than the
110 + * Google integrations declare itself ready, and lets tests force
111 + * either state.
112 + *
113 + * @since 2.8.0
114 + *
115 + * @param array $readiness See readiness().
116 + */
117 + $filtered = apply_filters('thinkrank_email_report_readiness', $readiness);
118 +
119 + return is_array($filtered) ? array_merge($readiness, $filtered) : $readiness;
120 + }
121 +
122 + /**
123 + * Subject-line tokens for a fetched report (#742).
124 + *
125 + * `%headline%` is the sentence the default subject is built from:
126 + * "12,480 Google clicks (+12.4%) in the last 30 days" when both windows
127 + * have totals, "Your SEO report for Aug 19 – Sep 17" when there is
128 + * nothing to compare. The parts are exposed as their own tokens so a
129 + * custom subject template can rebuild it differently.
130 + *
131 + * @param array $shared Output of fetch().
132 + * @param int $frequency_days Report window in days.
133 + * @return array<string,string> Token => value.
134 + */
135 + public static function subject_tokens(array $shared, int $frequency_days): array {
136 + $days = max(1, $frequency_days);
137 + $label = (string) ($shared['period_label'] ?? '');
138 + $totals = $shared['comparison']['totals'] ?? [];
139 + $current = is_array($totals['current'] ?? null) ? $totals['current'] : [];
140 + $previous = is_array($totals['previous'] ?? null) ? $totals['previous'] : [];
141 +
142 + $clicks = (int) ($current['clicks'] ?? 0);
143 + $impressions = (int) ($current['impressions'] ?? 0);
144 + if ($clicks === 0 && $impressions === 0) {
145 + $dash = $shared['current']['search_performance']['totals'] ?? [];
146 + $clicks = (int) ($dash['clicks'] ?? 0);
147 + $impressions = (int) ($dash['impressions'] ?? 0);
148 + }
149 +
150 + $change = null;
151 + if ((int) ($previous['clicks'] ?? 0) > 0 && class_exists(Email_Report_Sections\Email_Report_Html::class)) {
152 + $change = Email_Report_Sections\Email_Report_Html::pct_change((float) $clicks, (float) ($previous['clicks'] ?? 0));
153 + }
154 + $signed = $change !== null && $change['direction'] !== 'flat'
155 + ? ($change['direction'] === 'up' ? '+' : '−') . $change['text']
156 + : '';
157 +
158 + if ($clicks > 0 || $impressions > 0) {
159 + $headline = sprintf(
160 + /* translators: 1: number of clicks, 2: change in parentheses or empty, 3: number of days. */
161 + _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'),
162 + number_format_i18n($clicks),
163 + $signed !== '' ? ' (' . $signed . ')' : '',
164 + $days
165 + );
166 + } else {
167 + $headline = $label !== ''
168 + ? sprintf(
169 + /* translators: %s: period label, e.g. "Aug 19 – Sep 17, 2026". */
170 + __('Your SEO report for %s', 'thinkrank'),
171 + $label
172 + )
173 + : __('Your SEO report', 'thinkrank');
174 + }
175 +
176 + return [
177 + '%period%' => $label,
178 + '%period_days%' => (string) $days,
179 + '%clicks%' => number_format_i18n($clicks),
180 + '%clicks_change%' => $signed,
181 + '%impressions%' => number_format_i18n($impressions),
182 + '%headline%' => $headline,
183 + ];
184 + }
185 +
186 + /**
37 187 * Pull current + prior period dashboard data.
38 188 *
39 189 * @param int $frequency_days Reporting frequency in days.
40 190 * @return array{
@@ -87,10 +237,12 @@
87 237 $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
88 238
89 239 return [
90 240 'available' => true,
241 + 'readiness' => $this->readiness(),
91 242 'current' => is_array($current) ? $current : [],
92 243 'comparison' => $comparison,
244 + 'ai' => $this->ai_summary($frequency_days),
93 245 'period_start' => $period_start,
94 246 'period_end' => $period_end,
95 247 'period_label' => $period_label,
96 248 ];
@@ -126,9 +278,9 @@
126 278 * @param string $cur_end Current window end (Y-m-d).
127 279 * @return array{available:bool,queries:array,pages:array}
128 280 */
129 281 private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
130 - $empty = ['available' => false, 'queries' => [], 'pages' => []];
282 + $empty = ['available' => false, 'queries' => [], 'pages' => [], 'totals' => []];
131 283
132 284 if (!method_exists($manager, 'get_search_console_client')) {
133 285 return $empty;
134 286 }
@@ -145,21 +297,254 @@
145 297 // the current window opens.
146 298 $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($cur_start)));
147 299 $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days - 1) . ' days', strtotime($prev_end)));
148 300
149 - $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['query']);
150 - $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['query']);
151 - $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1000, ['page']);
152 - $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1000, ['page']);
301 + $limit = self::COMPARISON_ROW_LIMIT;
302 + $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, $limit, ['query']);
303 + $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, $limit, ['query']);
304 + $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, $limit, ['page']);
305 + $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, $limit, ['page']);
153 306
307 + // A row missing from a full current list may only have fallen below
308 + // the cut. Ask Search Console about the likely losers directly before
309 + // any of them is reported as having lost everything (#906).
310 + [$found_q, $dropouts_q] = $this->confirm_dropouts($sc, $site_url, $cur_start, $cur_end, $cur_q, $prev_q, 'query');
311 + [$found_p, $dropouts_p] = $this->confirm_dropouts($sc, $site_url, $cur_start, $cur_end, $cur_p, $prev_p, 'page');
312 +
313 + // Whole-property totals for both windows. A query with no
314 + // dimensions returns one aggregated row, so the hero's clicks,
315 + // impressions, CTR and position — and their change — are exact
316 + // rather than summed from the 1,000-row query lists above.
317 + $cur_t = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1, []);
318 + $prev_t = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1, []);
319 +
154 320 return [
155 321 'available' => true,
156 - 'queries' => $this->merge_periods($cur_q, $prev_q, true),
157 - 'pages' => $this->merge_periods($cur_p, $prev_p, false),
322 + 'queries' => $this->merge_periods(array_merge($cur_q, $found_q), $prev_q, true, $dropouts_q),
323 + 'pages' => $this->merge_periods(array_merge($cur_p, $found_p), $prev_p, false, $dropouts_p),
324 + 'totals' => [
325 + 'current' => $this->totals_row($cur_t),
326 + 'previous' => $this->totals_row($prev_t),
327 + ],
158 328 ];
159 329 }
160 330
161 331 /**
332 + * Sort out which previous-window rows missing from the current list
333 + * really lost all their traffic.
334 + *
335 + * Both windows are capped lists ordered by clicks. Absence from a
336 + * current list shorter than the cap is real: Search Console returned
337 + * everything it has. Absence from a full list is not evidence of
338 + * anything, because growth elsewhere pushes unchanged rows below the
339 + * cut. Those were synthesised as total losses and led the losing cards,
340 + * labelled "No impressions this period", with the same clicks in both
341 + * windows (#906).
342 + *
343 + * So when the current list is full, the biggest candidates by previous
344 + * clicks are looked up in the current window by exact key. A candidate
345 + * that comes back has its real current row returned, so its change is
346 + * measured rather than presumed. Only one that comes back empty is a
347 + * drop-out. Candidates past DROPOUT_CONFIRM_LIMIT, or in a batch whose
348 + * request failed, are neither: they are left out of the comparison,
349 + * since saying nothing is better than reporting a loss nobody measured.
350 + *
351 + * @since 2.15.0
352 + *
353 + * @param object $sc Search Console client.
354 + * @param string $site_url Property URL.
355 + * @param string $cur_start Current window start (Y-m-d).
356 + * @param string $cur_end Current window end (Y-m-d).
357 + * @param array $current Current-window rows, as fetched.
358 + * @param array $previous Previous-window rows, as fetched.
359 + * @param string $dimension 'query' or 'page'.
360 + * @return array{0: array, 1: ?array<string,bool>} Current rows found
361 + * below the cut, and the confirmed drop-out keys, or null when
362 + * the current list is complete and every absence is real.
363 + */
364 + private function confirm_dropouts($sc, string $site_url, string $cur_start, string $cur_end, array $current, array $previous, string $dimension): array {
365 + if (count($current) < self::COMPARISON_ROW_LIMIT) {
366 + return [[], null];
367 + }
368 +
369 + $is_query = 'query' === $dimension;
370 +
371 + $present = [];
372 + foreach ($current as $row) {
373 + $raw = (string) ($row['keys'][0] ?? '');
374 + if ('' !== $raw) {
375 + $present[$this->normalize_key($raw, $is_query)] = true;
376 + }
377 + }
378 +
379 + $candidates = [];
380 + foreach ($previous as $row) {
381 + $raw = (string) ($row['keys'][0] ?? '');
382 + if ('' === $raw) {
383 + continue;
384 + }
385 + $key = $this->normalize_key($raw, $is_query);
386 + if (!isset($present[$key]) && !isset($candidates[$key])) {
387 + $candidates[$key] = ['raw' => $raw, 'clicks' => (int) ($row['clicks'] ?? 0)];
388 + }
389 + }
390 +
391 + if ([] === $candidates) {
392 + return [[], []];
393 + }
394 +
395 + uasort($candidates, static fn($a, $b) => $b['clicks'] <=> $a['clicks']);
396 + $candidates = array_slice($candidates, 0, self::DROPOUT_CONFIRM_LIMIT, true);
397 +
398 + // Batch the exact-match lookups into as few requests as the
399 + // expression length allows: one per dimension in practice.
400 + $batches = [];
401 + $batch = [];
402 + $length = 0;
403 + foreach ($candidates as $key => $candidate) {
404 + $part = $this->re2_quote($candidate['raw']);
405 + if ([] !== $batch && $length + strlen($part) + 1 > self::DROPOUT_REGEX_MAX_LENGTH) {
406 + $batches[] = $batch;
407 + $batch = [];
408 + $length = 0;
409 + }
410 + $batch[$key] = $part;
411 + $length += strlen($part) + 1;
412 + }
413 + $batches[] = $batch;
414 +
415 + $found = [];
416 + $dropouts = [];
417 + foreach ($batches as $batch) {
418 + try {
419 + $rows = $sc->get_search_performance_by_dates(
420 + $site_url,
421 + $cur_start,
422 + $cur_end,
423 + count($batch),
424 + [$dimension],
425 + [[
426 + 'dimension' => $dimension,
427 + 'operator' => 'includingRegex',
428 + 'expression' => '^(?:' . implode('|', $batch) . ')$',
429 + ]]
430 + );
431 + } catch (Throwable $e) {
432 + // Unconfirmed: leave these out rather than call them lost.
433 + continue;
434 + }
435 +
436 + $seen = [];
437 + foreach ((array) $rows as $row) {
438 + $raw = (string) ($row['keys'][0] ?? '');
439 + $key = '' === $raw ? '' : $this->normalize_key($raw, $is_query);
440 + if (isset($batch[$key])) {
441 + $seen[$key] = true;
442 + $found[] = $row;
443 + }
444 + }
445 +
446 + foreach (array_keys($batch) as $key) {
447 + if (!isset($seen[$key])) {
448 + $dropouts[$key] = true;
449 + }
450 + }
451 + }
452 +
453 + return [$found, $dropouts];
454 + }
455 +
456 + /**
457 + * Escape a literal for a Search Console (RE2) regular expression.
458 + *
459 + * RE2 accepts a backslash before any ASCII punctuation as that literal,
460 + * so every punctuation character is escaped, not only the ones that are
461 + * special today.
462 + *
463 + * @since 2.15.0
464 + *
465 + * @param string $literal Text to match exactly.
466 + * @return string
467 + */
468 + private function re2_quote(string $literal): string {
469 + return (string) preg_replace('/[[:punct:]]/', '\\\\$0', $literal);
470 + }
471 +
472 + /**
473 + * Normalise the single aggregate row Search Console returns for a
474 + * dimensionless query. An empty result (a property with no traffic in
475 + * the window) yields zeroes, which the hero treats as "no comparison".
476 + *
477 + * @param array $rows API rows.
478 + * @return array{clicks:int,impressions:int,ctr:float,position:float}
479 + */
480 + private function totals_row(array $rows): array {
481 + $row = is_array($rows[0] ?? null) ? $rows[0] : [];
482 + return [
483 + 'clicks' => (int) ($row['clicks'] ?? 0),
484 + 'impressions' => (int) ($row['impressions'] ?? 0),
485 + 'ctr' => (float) ($row['ctr'] ?? 0.0),
486 + 'position' => (float) ($row['position'] ?? 0.0),
487 + ];
488 + }
489 +
490 + /**
491 + * AI-assistant traffic for the current window and the one before it,
492 + * from the first-party tracker AI Insights already runs. Null when the
493 + * tracker is absent or has recorded nothing.
494 + *
495 + * The tracker summarises a trailing window, so the previous period is
496 + * the double window minus the current one.
497 + *
498 + * @return array{current:array,previous:array}|null
499 + */
500 + private function ai_summary(int $frequency_days): ?array {
501 + $tracker = $this->get_ai_tracker();
502 + if ($tracker === null) {
503 + return null;
504 + }
505 + try {
506 + $current = (array) $tracker->summary($frequency_days);
507 + if ((int) ($current['ai_sessions'] ?? 0) === 0 && (int) ($current['baseline'] ?? 0) === 0) {
508 + return null;
509 + }
510 + $double = (array) $tracker->summary($frequency_days * 2);
511 + $previous = [
512 + 'ai_sessions' => max(0, (int) ($double['ai_sessions'] ?? 0) - (int) ($current['ai_sessions'] ?? 0)),
513 + 'baseline' => max(0, (int) ($double['baseline'] ?? 0) - (int) ($current['baseline'] ?? 0)),
514 + ];
515 + return ['current' => $current, 'previous' => $previous];
516 + } catch (Throwable $e) {
517 + return null;
518 + }
519 + }
520 +
521 + private function ai_tracker_has_data(): bool {
522 + $tracker = $this->get_ai_tracker();
523 + if ($tracker === null) {
524 + return false;
525 + }
526 + try {
527 + $summary = (array) $tracker->summary(30);
528 + return (int) ($summary['ai_sessions'] ?? 0) > 0 || (int) ($summary['baseline'] ?? 0) > 0;
529 + } catch (Throwable $e) {
530 + return false;
531 + }
532 + }
533 +
534 + private function get_ai_tracker() {
535 + $cls = '\\ThinkRank\\SEO\\Ai_Traffic_Tracker';
536 + if (!class_exists($cls) || !method_exists($cls, 'summary')) {
537 + return null;
538 + }
539 + try {
540 + return new $cls();
541 + } catch (Throwable $e) {
542 + return null;
543 + }
544 + }
545 +
546 + /**
162 547 * Merge current + previous GSC rows into one keyed map carrying both
163 548 * periods' clicks and (for queries) average position.
164 549 *
165 550 * The key set is the union of both windows. Search Console omits rows
@@ -167,14 +552,21 @@
167 552 * clicks has no current row at all — keying off `$current` alone would
168 553 * silently discard exactly the biggest losers the losing sections exist
169 554 * to surface.
170 555 *
171 - * @param array $current Current-window rows.
172 - * @param array $previous Previous-window rows.
173 - * @param bool $is_query True for query rows, false for page rows.
556 + * Which absent rows count as drop-outs is up to the caller. Absence from
557 + * a truncated list proves nothing, so build_comparison() passes the keys
558 + * it confirmed with Search Console; any other absent row is left out
559 + * (#906). Null means the current list was complete and every absence is
560 + * a drop-out.
561 + *
562 + * @param array $current Current-window rows.
563 + * @param array $previous Previous-window rows.
564 + * @param bool $is_query True for query rows, false for page rows.
565 + * @param array<string,bool>|null $dropout_keys Confirmed drop-out keys, or null for all.
174 566 * @return array<string,array>
175 567 */
176 - private function merge_periods(array $current, array $previous, bool $is_query): array {
568 + private function merge_periods(array $current, array $previous, bool $is_query, ?array $dropout_keys = null): array {
177 569 $prev_map = [];
178 570 foreach ($previous as $row) {
179 571 $key = (string) ($row['keys'][0] ?? '');
180 572 if ($key === '') {
@@ -212,8 +604,11 @@
212 604 foreach ($prev_map as $key => $prev_row) {
213 605 if (isset($merged[$key])) {
214 606 continue;
215 607 }
608 + if (null !== $dropout_keys && !isset($dropout_keys[$key])) {
609 + continue;
610 + }
216 611 $raw = (string) ($prev_row['keys'][0] ?? '');
217 612 if ($raw === '') {
218 613 continue;
219 614 }
@@ -236,8 +631,29 @@
236 631 }
237 632
238 633 private function normalize_key(string $key, bool $is_query): string {
239 634 return $is_query ? trim(strtolower($key)) : $key;
635 + }
636 +
637 + /**
638 + * The plugin settings store, or a null-object when it is not loaded
639 + * (unit tests without the core classes), which reads as "nothing
640 + * configured".
641 + */
642 + private function settings() {
643 + $cls = '\\ThinkRank\\Core\\Settings';
644 + if (class_exists($cls) && method_exists($cls, 'instance')) {
645 + try {
646 + return $cls::instance();
647 + } catch (Throwable $e) {
648 + // Fall through to the null object.
649 + }
650 + }
651 + return new class() {
652 + public function get(string $key, $fallback = null) {
653 + return $fallback;
654 + }
655 + };
240 656 }
241 657
242 658 private function get_analytics_manager() {
243 659 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';