PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.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 1.10.0 All 48 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.4.0, at includes/seo/class-email-report-data-provider.php

272 lines 10.1 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 * 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.
13 *
14 * @package ThinkRank
15 * @subpackage SEO
16 * @since 1.9.0
17 */
18
19 declare(strict_types=1);
20
21 namespace ThinkRank\SEO;
22
23 use Throwable;
24
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Email_Report_Data_Provider
31 *
32 * @since 1.9.0
33 */
34 final class Email_Report_Data_Provider {
35
36 /**
37 * Pull current + prior period dashboard data.
38 *
39 * @param int $frequency_days Reporting frequency in days.
40 * @return array{
41 * available:bool,
42 * current:array,
43 * prior:array,
44 * period_start:string,
45 * period_end:string,
46 * period_label:string,
47 * error?:string
48 * }
49 */
50 public function fetch(int $frequency_days): array {
51 $frequency_days = max(1, $frequency_days);
52
53 // One canonical window for the whole report. Search Console lags
54 // ~2 days, so it ends on the last date that actually has data —
55 // the same anchor Analytics_Manager::get_dashboard_data() uses for
56 // Key Metrics and Position Summary. Previously the header label ran
57 // through today and the comparison through yesterday, so a reader
58 // was handed three windows and told they were one.
59 $period_end = gmdate('Y-m-d', strtotime('-2 days'));
60 $period_start = gmdate(
61 'Y-m-d',
62 strtotime('-' . ($frequency_days - 1) . ' days', strtotime($period_end))
63 );
64 $period_label = $this->format_period_label($period_start, $period_end);
65
66 $manager = $this->get_analytics_manager();
67 if ($manager === null) {
68 return [
69 'available' => false,
70 'current' => [],
71 'prior' => [],
72 'period_start' => $period_start,
73 'period_end' => $period_end,
74 'period_label' => $period_label,
75 'error' => __('Analytics integration not available.', 'thinkrank'),
76 ];
77 }
78
79 try {
80 $range = $frequency_days . 'd';
81 $current = $manager->get_dashboard_data($range);
82
83 // Real period-over-period comparison: pull query- and page-level
84 // metrics for the current window AND the immediately preceding
85 // window of equal length straight from Search Console, then key
86 // them so winning/losing sections can compute true deltas.
87 $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
88
89 return [
90 'available' => true,
91 'current' => is_array($current) ? $current : [],
92 'comparison' => $comparison,
93 'period_start' => $period_start,
94 'period_end' => $period_end,
95 'period_label' => $period_label,
96 ];
97 } catch (Throwable $e) {
98 return [
99 'available' => false,
100 'current' => [],
101 'comparison' => ['available' => false, 'queries' => [], 'pages' => []],
102 'period_start' => $period_start,
103 'period_end' => $period_end,
104 'period_label' => $period_label,
105 'error' => $e->getMessage(),
106 ];
107 }
108 }
109
110 /**
111 * Build the current-vs-previous comparison from Search Console.
112 *
113 * Uses the Search Console client's arbitrary date-range API
114 * (`get_search_performance_by_dates`) — the same one the Rank Tracker
115 * and the Pro winning/losing endpoint use — to fetch query- and
116 * page-level rows for two equal, adjacent windows.
117 *
118 * The current window is handed in by fetch() rather than recomputed
119 * here, so the deltas describe exactly the period the report's header
120 * advertises. The previous window is the same length, immediately
121 * before it, with no gap or overlap.
122 *
123 * @param object $manager Analytics_Manager instance.
124 * @param int $frequency_days Window length in days.
125 * @param string $cur_start Current window start (Y-m-d).
126 * @param string $cur_end Current window end (Y-m-d).
127 * @return array{available:bool,queries:array,pages:array}
128 */
129 private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
130 $empty = ['available' => false, 'queries' => [], 'pages' => []];
131
132 if (!method_exists($manager, 'get_search_console_client')) {
133 return $empty;
134 }
135 $sc = $manager->get_search_console_client();
136 if (!$sc || !method_exists($sc, 'get_search_performance_by_dates')) {
137 return $empty;
138 }
139 $site_url = method_exists($manager, 'get_property_url') ? (string) $manager->get_property_url() : '';
140 if ($site_url === '') {
141 return $empty;
142 }
143
144 // Previous window: the same number of days, ending the day before
145 // the current window opens.
146 $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($cur_start)));
147 $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days - 1) . ' days', strtotime($prev_end)));
148
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']);
153
154 return [
155 'available' => true,
156 'queries' => $this->merge_periods($cur_q, $prev_q, true),
157 'pages' => $this->merge_periods($cur_p, $prev_p, false),
158 ];
159 }
160
161 /**
162 * Merge current + previous GSC rows into one keyed map carrying both
163 * periods' clicks and (for queries) average position.
164 *
165 * The key set is the union of both windows. Search Console omits rows
166 * with no activity in a window, so a page or query that dropped to zero
167 * clicks has no current row at all — keying off `$current` alone would
168 * silently discard exactly the biggest losers the losing sections exist
169 * to surface.
170 *
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.
174 * @return array<string,array>
175 */
176 private function merge_periods(array $current, array $previous, bool $is_query): array {
177 $prev_map = [];
178 foreach ($previous as $row) {
179 $key = (string) ($row['keys'][0] ?? '');
180 if ($key === '') {
181 continue;
182 }
183 $prev_map[$this->normalize_key($key, $is_query)] = $row;
184 }
185
186 $merged = [];
187 foreach ($current as $row) {
188 $raw = (string) ($row['keys'][0] ?? '');
189 if ($raw === '') {
190 continue;
191 }
192 $key = $this->normalize_key($raw, $is_query);
193 $prev = $prev_map[$key] ?? null;
194
195 $entry = [
196 'cur_clicks' => (int) ($row['clicks'] ?? 0),
197 'prev_clicks' => $prev ? (int) ($prev['clicks'] ?? 0) : 0,
198 ];
199 if ($is_query) {
200 $entry['query'] = $raw;
201 $entry['cur_pos'] = round((float) ($row['position'] ?? 0), 1);
202 $entry['prev_pos'] = $prev ? round((float) ($prev['position'] ?? 0), 1) : null;
203 } else {
204 $entry['url'] = $raw;
205 }
206 $merged[$key] = $entry;
207 }
208
209 // Total drop-outs: present last period, absent now. Synthesize them
210 // from the previous window with the current metrics zeroed. Position
211 // stays null rather than 0 — "no data" is not "ranked first".
212 foreach ($prev_map as $key => $prev_row) {
213 if (isset($merged[$key])) {
214 continue;
215 }
216 $raw = (string) ($prev_row['keys'][0] ?? '');
217 if ($raw === '') {
218 continue;
219 }
220
221 $entry = [
222 'cur_clicks' => 0,
223 'prev_clicks' => (int) ($prev_row['clicks'] ?? 0),
224 ];
225 if ($is_query) {
226 $entry['query'] = $raw;
227 $entry['cur_pos'] = null;
228 $entry['prev_pos'] = round((float) ($prev_row['position'] ?? 0), 1);
229 } else {
230 $entry['url'] = $raw;
231 }
232 $merged[$key] = $entry;
233 }
234
235 return $merged;
236 }
237
238 private function normalize_key(string $key, bool $is_query): string {
239 return $is_query ? trim(strtolower($key)) : $key;
240 }
241
242 private function get_analytics_manager() {
243 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';
244 if (!class_exists($cls)) {
245 return null;
246 }
247 try {
248 return new $cls();
249 } catch (Throwable $e) {
250 return null;
251 }
252 }
253
254 private function format_period_label(string $start, string $end): string {
255 $fmt = (string) get_option('date_format', 'M j, Y');
256 return sprintf('%s – %s', $this->format_day($start, $fmt), $this->format_day($end, $fmt));
257 }
258
259 /**
260 * Render a bare Y-m-d as a localized date.
261 *
262 * Anchored at midday UTC on purpose: wp_date() shifts the timestamp into
263 * the site timezone, and a date parsed at midnight would render as the
264 * day before on any negative offset. Midday leaves the calendar date
265 * intact across every real-world offset.
266 */
267 private function format_day(string $date, string $format): string {
268 $ts = strtotime($date . ' 12:00:00 UTC');
269 return wp_date($format, $ts ?: time());
270 }
271 }
272