PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / 0.9.4
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress v0.9.4
0.9.4 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.2 0.8.1 0.7.9 0.8.0 0.7.7 0.7.8 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.5 All 88 releases
seo-engine / classes / modules / googleanalytics.php

googleanalytics.php in SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress 0.9.4, at classes/modules/googleanalytics.php

1,288 lines 38.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // TODO [2025]: Refactor to unified analytics provider interface
4 class Meow_MWSEO_Modules_GoogleAnalytics
5 {
6 private $core = null;
7
8 private $property_ids = array();
9 private $property_id = null;
10 private $client_secret = null;
11 private $client_id = null;
12
13 private $use_cache = false;
14 private $disabled_tracking = false;
15 private $measurement_ids = array();
16 private $main_measurement_id = null;
17 private $other_measurement_ids = array();
18
19 private $current_access_token = null;
20 private $current_refresh_token = null;
21 private $current_expires_at = null;
22 private $last_api_error = null;
23
24 const TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token';
25 const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
26 const SCOPE_URL = 'https://www.googleapis.com/auth/analytics.readonly';
27
28 private $base_url = 'https://analyticsdata.googleapis.com/v1beta/';
29 private $realtime_url = 'https://analyticsdata.googleapis.com/v1beta/';
30
31 // Tokens live in an option, not a transient: an external object cache (Redis, Memcache)
32 // or any cache-flushing plugin wipes transients, which silently disconnected Google.
33 const OPTION_TOKEN_INFO = 'mwseo_google_analytics_token_info';
34 const TRANSIENT_REPORT_PREFIX = 'mwseo_google_analytics_report_';
35
36 public function __construct( $core )
37 {
38 $this->core = $core;
39 $this->init();
40
41 $this->handle_authentication();
42 $this->tracking();
43 }
44
45 #region Authentication
46
47 public function get_redirect_url() {
48 return admin_url( 'admin.php?page=mwseo_settings&nekoTab=settings' );
49 }
50
51
52
53 /**
54 * Initialize the module and load settings.
55 */
56
57 public function init()
58 {
59 $this->use_cache = $this->core->get_option( 'analytics_cache', false );
60 $this->property_ids = $this->core->get_option( 'google_analytics_property_ids', array() );
61 $this->property_id = $this->core->get_option( 'google_analytics_property_id' );
62
63 if ( empty( $this->property_id ) && !empty( $this->property_ids ) ) {
64 $this->property_id = $this->property_ids[0];
65 }
66
67 $this->client_secret = $this->core->get_option( 'google_analytics_client_secret', '' );
68 $this->client_id = $this->core->get_option( 'google_analytics_client_id', '' );
69
70 if ( empty( $this->client_secret ) || empty( $this->client_id ) ) {
71 //$this->core->log( "⚠️ Google Analytics not configured. Please set your Client ID and Client Secret in the settings." );
72 return;
73 }
74
75 $token_info = $this->get_token_info();
76
77 if ( $token_info && isset( $token_info['access_token'] ) ) {
78 $this->current_access_token = $token_info['access_token'];
79 }
80 if ( $token_info && isset( $token_info['refresh_token'] ) ) {
81 $this->current_refresh_token = $token_info['refresh_token'];
82 }
83 if ( $token_info && isset( $token_info['expires_at'] ) ) {
84 $this->current_expires_at = $token_info['expires_at'];
85 }
86
87 }
88
89 /**
90 * Read the stored token info, migrating it out of the legacy transient on the way.
91 */
92 private function get_token_info() {
93 $token_info = get_option( self::OPTION_TOKEN_INFO );
94
95 if ( empty( $token_info ) ) {
96 $legacy = get_transient( self::OPTION_TOKEN_INFO );
97 if ( is_array( $legacy ) ) {
98 $token_info = $legacy;
99 $this->set_token_info( $legacy );
100 }
101 delete_transient( self::OPTION_TOKEN_INFO );
102 }
103
104 return is_array( $token_info ) ? $token_info : null;
105 }
106
107 private function set_token_info( $token_info ) {
108 update_option( self::OPTION_TOKEN_INFO, $token_info, false );
109 }
110
111 private function check_properties() {
112 if ( empty( $this->property_id ) || ! in_array( $this->property_id, $this->property_ids ) ) {
113 $this->core->log( "⚠️ Google Analytics property not set or invalid." );
114 return false;
115 }
116
117 return true;
118 }
119
120 public function is_authenticated() {
121
122 if ( empty( $this->client_secret ) || empty( $this->client_id ) ) {
123 return false;
124 }
125
126 // We don't need a property to be authenticated
127 // if ( empty( $this->property_id ) || ! in_array( $this->property_id, $this->property_ids ) ) {
128 // $this->core->log( "⚠️ Google Analytics property not set or invalid." );
129 // return false;
130 // }
131
132 if ( ! $this->current_access_token || ! $this->current_refresh_token || ! $this->current_expires_at ) {
133 return false;
134 }
135
136 if ( time() >= $this->current_expires_at ) {
137 if ( ! $this->get_refresh_token() ) {
138 return false;
139 }
140 }
141
142 return true;
143 }
144
145 public function get_last_error() {
146 return $this->last_api_error;
147 }
148
149 public function unlink() {
150 $this->core->log( "🔗 Unlinking Google Analytics." );
151
152 delete_option( self::OPTION_TOKEN_INFO );
153 delete_transient( self::OPTION_TOKEN_INFO );
154
155 $this->current_access_token = null;
156 $this->current_refresh_token = null;
157 $this->current_expires_at = null;
158
159 return true;
160 }
161
162 /**
163 * Handle the authentication process when the user is redirected back from Google.
164 */
165
166 public function handle_authentication() {
167
168 if ( ! isset( $_GET['code'] ) ) {
169 return false;
170 }
171
172 // Other modules (e.g. Search Console) use the same redirect URL and disambiguate
173 // via the OAuth `state` parameter. Only handle callbacks intended for GA.
174 if ( !empty( $_GET['state'] ) && $_GET['state'] !== 'mwseo_ga' ) {
175 return false;
176 }
177
178 $code = sanitize_text_field( $_GET['code'] );
179
180 if ( empty( $code ) ) {
181 return false;
182 }
183
184 if ( $this->get_access_token( $code ) ) {
185 return true;
186 } else {
187 $this->core->log( "⚠️ Failed to authenticate with Google Analytics." );
188 return false;
189 }
190 }
191
192 public function get_auth_url() {
193
194 $params = array(
195 'response_type' => 'code',
196 'client_id' => $this->client_id,
197 'redirect_uri' => $this->get_redirect_url(),
198 'scope' => self::SCOPE_URL,
199 'access_type' => 'offline',
200 'prompt' => 'consent',
201 'state' => 'mwseo_ga'
202 );
203
204 return self::AUTH_URL . '?' . http_build_query( $params );
205 }
206
207 private function get_access_token( $code ) {
208
209 if( $this->is_authenticated() ) {
210 return true;
211 }
212
213 $options = array(
214 'body' => array(
215 'code' => $code,
216 'client_id' => $this->client_id,
217 'client_secret' => $this->client_secret,
218 'redirect_uri' => $this->get_redirect_url(),
219 'grant_type' => 'authorization_code'
220 )
221 );
222
223 $result = wp_remote_post( self::TOKEN_URL, $options );
224
225 if ( is_wp_error( $result ) ) {
226 $this->core->log( "⚠️ Error retrieving access token: " . $result->get_error_message() );
227 return false;
228 }
229
230 $json = json_decode( $result['body'] );
231
232 if ( isset( $json->error ) ) {
233 $this->core->log( "⚠️ Error retrieving access token: " . $json->error );
234 return false;
235 }
236
237 $expires_in = isset( $json->expires_in ) ? $json->expires_in : 3600;
238 $access_token = isset( $json->access_token ) ? $json->access_token : null;
239 $refresh_token = isset( $json->refresh_token ) ? $json->refresh_token : null;
240
241 $token_info = array(
242 'expires_in' => $expires_in,
243 'expires_at' => time() + $expires_in,
244 'access_token' => $access_token,
245 'refresh_token' => $refresh_token
246 );
247
248 $this->set_token_info( $token_info );
249
250 // Update instance variables so current request can use the new token
251 $this->current_access_token = $access_token;
252 $this->current_refresh_token = $refresh_token;
253 $this->current_expires_at = time() + $expires_in;
254
255 return true;
256 }
257
258 public function get_refresh_token() {
259
260 $options = array(
261 'body' => array(
262 'client_id' => $this->client_id,
263 'client_secret' => $this->client_secret,
264 'refresh_token' => $this->current_refresh_token,
265 'grant_type' => 'refresh_token'
266 )
267 );
268
269 $result = wp_remote_post( self::TOKEN_URL, $options );
270
271 if ( is_wp_error( $result ) ) {
272 $this->core->log( "⚠️ Error refreshing access token: " . $result->get_error_message() );
273 return false;
274 }
275
276 $json = json_decode( $result['body'] );
277 if ( isset( $json->error ) ) {
278 $this->core->log( "⚠️ Error refreshing access token: " . $json->error );
279 return false;
280 }
281
282 $expires_in = isset( $json->expires_in ) ? $json->expires_in : 3600;
283 $access_token = isset( $json->access_token ) ? $json->access_token : null;
284
285 $token_info = array(
286 'expires_in' => $expires_in,
287 'expires_at' => time() + $expires_in,
288 'access_token' => $access_token,
289 'refresh_token' => $this->current_refresh_token
290 );
291
292 $this->set_token_info( $token_info );
293
294 // Update instance variables so current request uses the new token
295 $this->current_access_token = $access_token;
296 $this->current_expires_at = time() + $expires_in;
297
298 return true;
299 }
300
301 #endregion
302
303 #region Tracking
304
305 public function tracking() {
306
307 if ( is_admin() ) { return; }
308
309 $this->disabled_tracking = $this->core->get_option( 'google_analytics_tracking_disabled', false );
310 $this->measurement_ids = $this->core->get_option( 'google_analytics_tracking_ids', array() );
311
312 if ( $this->disabled_tracking || empty( $this->measurement_ids ) ) {
313 return;
314 }
315
316 $this->main_measurement_id = $this->measurement_ids[0];
317 $this->other_measurement_ids = array_slice( $this->measurement_ids, 1 );
318
319 add_action( 'wp_enqueue_scripts', array( $this, 'wp_enqueue_tracking_scripts' ) );
320 }
321
322 public function wp_enqueue_tracking_scripts() {
323
324 // Don't track logged-in users
325 $is_logged_in = is_user_logged_in();
326 if ( $is_logged_in ) {
327 $track_logged_users = $this->core->get_option( 'google_analytics_track_logged_users', false );
328 if ( !$track_logged_users ) { return; }
329
330 // Don't track editors and admins
331 $is_power_user = current_user_can( 'editor' ) || current_user_can( 'administrator' );
332 if ( $is_power_user ) {
333 $track_power_users = $this->core->get_option( 'google_analytics_track_power_users', false );
334 if ( !$track_power_users ) { return; }
335 }
336 }
337
338 add_filter( 'script_loader_tag', array( $this, 'script_loader_tag' ), 10, 2 );
339
340 // Enqueue the Google Analytics script
341 wp_register_script( 'mwseo-analytics-ga', 'https://www.googletagmanager.com/gtag/js?id=' . $this->main_measurement_id, array(), null, true );
342 wp_enqueue_script( 'mwseo-analytics-ga' );
343
344 // Add the inline script with the tracking code
345 wp_add_inline_script( 'mwseo-analytics-ga', $this->build_js(), 'after' );
346 }
347
348 function build_js( )
349 {
350 $main_measurement_id = $this->main_measurement_id;
351 $other_measurement_ids = $this->other_measurement_ids;
352
353 $extra_ids = json_encode( $other_measurement_ids );
354
355 return "
356 var extra_ids = {$extra_ids};
357 window.dataLayer = window.dataLayer || [];
358 function gtag(){dataLayer.push(arguments);}
359 gtag('js', new Date());
360 gtag('config', '{$main_measurement_id}');
361 for (var i = 0; i < extra_ids.length; i++) {
362 gtag('config', extra_ids[i]);
363 }
364 ";
365 }
366
367 function script_loader_tag($tag, $handle)
368 {
369 if ($handle === 'mwseo-analytics-ga') {
370 $tag = str_replace('<script src=', '<script async src=', $tag);
371 return $tag;
372 }
373 return $tag;
374 }
375
376 #endregion
377
378 #region Analytics
379
380
381 // TODO [2025]: Refactor to unified analytics provider interface
382 public function get_analytics_data( $args = array() )
383 {
384
385 if ( !$this->is_authenticated() ) {
386 return array();
387 }
388
389 if ( !$this->check_properties() ) {
390 return array();
391 }
392
393 $defaults = array(
394 'start_date' => date( 'Y-m-d', strtotime( '-30 days' ) ),
395 'end_date' => date( 'Y-m-d' ),
396 'group_by' => 'day',
397 'limit' => 100
398 );
399
400 $args = wp_parse_args( $args, $defaults );
401
402 // Check if there is cached version of the report
403 if ( $this->use_cache ) {
404
405 $prefix = self::TRANSIENT_REPORT_PREFIX . 'analytics_data_' . $this->property_id . '_';
406 $transient_key = $prefix . md5( serialize( $args ) );
407
408 $cached_report = get_transient( $transient_key );
409 if ( $cached_report !== false ) {
410 return $cached_report;
411 }
412
413 }
414
415
416 try {
417 // Use actual Google Analytics Data API
418 $report = $this->get_analytics_data_from_api( $args );
419
420 if ( !empty( $report ) && $this->use_cache ) {
421 // Cache the report
422 set_transient( $transient_key, $report, 12 * HOUR_IN_SECONDS );
423 }
424
425 return $report;
426
427 } catch ( Exception $e ) {
428 $this->core->log( "❌ Google Analytics API Error: " . $e->getMessage() );
429 throw $e; // Re-throw to propagate to REST endpoint
430 }
431 }
432
433 // TODO [2025]: Refactor to unified analytics provider interface
434 public function get_analytics_summary( $start_date = null, $end_date = null )
435 {
436 if ( !$this->is_authenticated() ) {
437 return array();
438 }
439
440 if ( !$this->check_properties() ) {
441 return array();
442 }
443
444 if ( !$start_date ) {
445 $start_date = date( 'Y-m-d', strtotime( '-30 days' ) );
446 }
447 if ( !$end_date ) {
448 $end_date = date( 'Y-m-d' );
449 }
450
451 $args = array(
452 'start_date' => $start_date,
453 'end_date' => $end_date
454 );
455
456 // Check if there is cached version of the report
457 if ( $this->use_cache ) {
458 $prefix = self::TRANSIENT_REPORT_PREFIX . 'analytics_summary_' . $this->property_id . '_';
459 $transient_key = $prefix . md5( serialize( $args ) );
460
461 $cached_report = get_transient( $transient_key );
462 if ( $cached_report !== false ) {
463 return $cached_report;
464 }
465 }
466
467 try {
468 // Use actual Google Analytics Data API
469 $result = $this->get_analytics_summary_from_api( $start_date, $end_date );
470
471 if ( !empty( $result ) && $this->use_cache ) {
472 // Cache the report
473 set_transient( $transient_key, $result, 12 * HOUR_IN_SECONDS );
474 }
475
476 return $result;
477
478 } catch ( Exception $e ) {
479 $this->core->log( "❌ Google Analytics API Error: " . $e->getMessage() );
480 throw $e; // Re-throw to propagate to REST endpoint
481 }
482 }
483
484 // TODO [2025]: Refactor to unified analytics provider interface
485 public function get_post_analytics( $page_path, $start_date = null, $end_date = null )
486 {
487 if ( !$this->is_authenticated() ) {
488 return array();
489 }
490 if ( !$this->check_properties() ) {
491 return array();
492 }
493
494 if ( !$start_date ) {
495 $start_date = date( 'Y-m-d', strtotime( '-30 days' ) );
496 }
497 if ( !$end_date ) {
498 $end_date = date( 'Y-m-d' );
499 }
500
501 $request_data = array(
502 'dateRanges' => array(
503 array(
504 'startDate' => $start_date,
505 'endDate' => $end_date
506 )
507 ),
508 'dimensions' => array(
509 array( 'name' => 'pagePath' ),
510 array( 'name' => 'hostName' )
511 ),
512 'metrics' => array(
513 array( 'name' => 'sessions' ),
514 array( 'name' => 'totalUsers' ),
515 array( 'name' => 'screenPageViews' ),
516 array( 'name' => 'averageSessionDuration' ),
517 array( 'name' => 'bounceRate' )
518 ),
519 'dimensionFilter' => array(
520 'filter' => array(
521 'fieldName' => 'pagePath',
522 'stringFilter' => array(
523 'matchType' => 'EXACT',
524 'value' => $page_path
525 )
526 )
527 ),
528 'limit' => 1
529 );
530
531 $response = $this->make_google_analytics_request( 'runReport', $request_data );
532
533 if ( !$response || !isset( $response['rows'] ) || empty( $response['rows'] ) ) {
534 return array();
535 }
536
537 $row = $response['rows'][0];
538 $host = isset( $row['dimensionValues'][1]['value'] ) ? $row['dimensionValues'][1]['value'] : '';
539
540 return array(
541 'visits' => isset( $row['metricValues'][0]['value'] ) ? (int) $row['metricValues'][0]['value'] : 0,
542 'unique_visitors' => isset( $row['metricValues'][1]['value'] ) ? (int) $row['metricValues'][1]['value'] : 0,
543 'pageviews' => isset( $row['metricValues'][2]['value'] ) ? (int) $row['metricValues'][2]['value'] : 0,
544 'avg_time_on_page' => isset( $row['metricValues'][3]['value'] ) ? round( (float) $row['metricValues'][3]['value'] ) : 0,
545 'bounce_rate' => isset( $row['metricValues'][4]['value'] ) ? round( (float) $row['metricValues'][4]['value'] * 100, 1 ) : 0,
546 'page_path' => $page_path,
547 'host' => $host
548 );
549 }
550
551 /**
552 * Batched daily visitors for every page in ONE report (date x hostName x pagePath -> totalUsers).
553 * Powers the per-post visitor sparklines in the Content SEO list without one API call per post.
554 * Returns: [ ['date' => 'YYYY-MM-DD', 'host' => '...', 'path' => '/...', 'visitors' => int], ... ]
555 */
556 public function get_pages_daily_visitors( $start_date = null, $end_date = null, $paths = null )
557 {
558 if ( !$this->is_authenticated() ) {
559 return array();
560 }
561 if ( !$start_date ) $start_date = date( 'Y-m-d', strtotime( '-30 days' ) );
562 if ( !$end_date ) $end_date = date( 'Y-m-d' );
563
564 // Scope the report to the requested paths whenever the caller can name them (the
565 // sparklines only ever need the visible rows). The unscoped date x host x path
566 // report can reach 100k rows on a multi-domain site, and decoding that JSON
567 // exhausted PHP memory (fatal 500 that took the whole Content SEO list down).
568 $paths = is_array( $paths ) ? array_values( array_unique( array_filter( array_map( 'strval', $paths ) ) ) ) : null;
569 if ( $paths !== null && ( empty( $paths ) || count( $paths ) > 200 ) ) {
570 $paths = null;
571 }
572
573 // Honour the Display Cache setting: this is a heavy report pulled on every list load, so
574 // caching it (12h) spares the GA4 API and quota. Recommended on for API-based sources.
575 $transient_key = self::TRANSIENT_REPORT_PREFIX . 'pages_daily_' . $this->property_id . '_'
576 . md5( $start_date . '|' . $end_date . '|' . ( $paths ? implode( ',', $paths ) : 'all' ) );
577 if ( $this->use_cache ) {
578 $cached = get_transient( $transient_key );
579 if ( $cached !== false ) return $cached;
580 }
581
582 // Busiest page-days first, capped, so even the unscoped fallback stays bounded.
583 $request_data = array(
584 'dateRanges' => array( array( 'startDate' => $start_date, 'endDate' => $end_date ) ),
585 'dimensions' => array(
586 array( 'name' => 'date' ),
587 array( 'name' => 'hostName' ),
588 array( 'name' => 'pagePath' )
589 ),
590 'metrics' => array( array( 'name' => 'totalUsers' ) ),
591 'orderBys' => array( array( 'metric' => array( 'metricName' => 'totalUsers' ), 'desc' => true ) ),
592 'limit' => 20000
593 );
594 if ( $paths ) {
595 $request_data['dimensionFilter'] = array(
596 'filter' => array(
597 'fieldName' => 'pagePath',
598 'inListFilter' => array( 'values' => $paths, 'caseSensitive' => false )
599 )
600 );
601 }
602
603 // A GA4 hiccup (quota, transient API error) must degrade to "no data", not throw
604 // through the callers and kill whatever REST request needed these numbers.
605 try {
606 $response = $this->make_google_analytics_request( 'runReport', $request_data );
607 }
608 catch ( Exception $e ) {
609 return array();
610 }
611 if ( !$response || empty( $response['rows'] ) ) {
612 return array();
613 }
614
615 $out = array();
616 foreach ( $response['rows'] as $row ) {
617 $d = isset( $row['dimensionValues'][0]['value'] ) ? $row['dimensionValues'][0]['value'] : ''; // YYYYMMDD
618 $host = isset( $row['dimensionValues'][1]['value'] ) ? $row['dimensionValues'][1]['value'] : '';
619 $path = isset( $row['dimensionValues'][2]['value'] ) ? $row['dimensionValues'][2]['value'] : '';
620 $visitors = isset( $row['metricValues'][0]['value'] ) ? (int) $row['metricValues'][0]['value'] : 0;
621 if ( strlen( $d ) !== 8 || $path === '' ) continue;
622 // Noise hosts would otherwise sneak into posts via the host-agnostic path fallback.
623 if ( $this->is_noise_host( $host ) ) continue;
624 $out[] = array(
625 'date' => substr( $d, 0, 4 ) . '-' . substr( $d, 4, 2 ) . '-' . substr( $d, 6, 2 ),
626 'host' => $host,
627 'path' => $path,
628 'visitors' => $visitors
629 );
630 }
631
632 if ( $this->use_cache ) {
633 set_transient( $transient_key, $out, 12 * HOUR_IN_SECONDS );
634 }
635 return $out;
636 }
637
638 /**
639 * Total visitors per page over the window, in ONE report without the date dimension:
640 * one row per host+path (a few thousand rows at most) instead of one per page-day.
641 * This is what the posts-list "Visitors" sort uses; it has to stay light enough to
642 * run at the end of an already memory-heavy request on 128M hosts.
643 * Returns: [ ['host' => '...', 'path' => '/...', 'visitors' => int], ... ]
644 */
645 public function get_pages_total_visitors( $start_date = null, $end_date = null )
646 {
647 if ( !$this->is_authenticated() ) {
648 return array();
649 }
650 if ( !$start_date ) $start_date = date( 'Y-m-d', strtotime( '-30 days' ) );
651 if ( !$end_date ) $end_date = date( 'Y-m-d' );
652
653 $transient_key = self::TRANSIENT_REPORT_PREFIX . 'pages_totals_' . $this->property_id . '_' . md5( $start_date . '|' . $end_date );
654 if ( $this->use_cache ) {
655 $cached = get_transient( $transient_key );
656 if ( $cached !== false ) return $cached;
657 }
658
659 $request_data = array(
660 'dateRanges' => array( array( 'startDate' => $start_date, 'endDate' => $end_date ) ),
661 'dimensions' => array(
662 array( 'name' => 'hostName' ),
663 array( 'name' => 'pagePath' )
664 ),
665 'metrics' => array( array( 'name' => 'totalUsers' ) ),
666 'orderBys' => array( array( 'metric' => array( 'metricName' => 'totalUsers' ), 'desc' => true ) ),
667 'limit' => 20000
668 );
669
670 try {
671 $response = $this->make_google_analytics_request( 'runReport', $request_data );
672 }
673 catch ( Exception $e ) {
674 return array();
675 }
676 if ( !$response || empty( $response['rows'] ) ) {
677 return array();
678 }
679
680 $out = array();
681 foreach ( $response['rows'] as $row ) {
682 $host = isset( $row['dimensionValues'][0]['value'] ) ? $row['dimensionValues'][0]['value'] : '';
683 $path = isset( $row['dimensionValues'][1]['value'] ) ? $row['dimensionValues'][1]['value'] : '';
684 $visitors = isset( $row['metricValues'][0]['value'] ) ? (int) $row['metricValues'][0]['value'] : 0;
685 if ( $path === '' || $this->is_noise_host( $host ) ) continue;
686 $out[] = array( 'host' => $host, 'path' => $path, 'visitors' => $visitors );
687 }
688
689 if ( $this->use_cache ) {
690 set_transient( $transient_key, $out, 12 * HOUR_IN_SECONDS );
691 }
692 return $out;
693 }
694
695 public function get_top_posts( $args = array() )
696 {
697 if ( !$this->is_authenticated() ) {
698 return array();
699 }
700
701 if ( !$this->check_properties() ) {
702 return array();
703 }
704
705 $defaults = array(
706 'start_date' => date( 'Y-m-d', strtotime( '-30 days' ) ),
707 'end_date' => date( 'Y-m-d' ),
708
709 // ! We override the limits in the API call. We should have setting later if needed.
710 );
711
712 $args = wp_parse_args( $args, $defaults );
713
714 // Check if there is cached version of the report
715 if ( $this->use_cache ) {
716 $prefix = self::TRANSIENT_REPORT_PREFIX . 'top_posts_' . $this->property_id . '_';
717 $transient_key = $prefix . md5( serialize( $args ) );
718
719 $cached_report = get_transient( $transient_key );
720 if ( $cached_report !== false ) {
721 return $cached_report;
722 }
723 }
724
725 try {
726 // Use actual Google Analytics Data API
727 $result = $this->get_top_posts_from_api( $args );
728
729 if ( !empty( $result ) && $this->use_cache ) {
730 // Cache the report
731 set_transient( $transient_key, $result, 12 * HOUR_IN_SECONDS );
732 }
733
734 return $result;
735
736 } catch ( Exception $e ) {
737 $this->core->log( "❌ Google Analytics API Error: " . $e->getMessage() );
738 throw $e; // Re-throw to propagate to REST endpoint
739 }
740 }
741
742 // Google Analytics Data API methods
743 private function get_analytics_data_from_api( $args )
744 {
745 $date_ranges = array(
746 array(
747 'startDate' => $args['start_date'],
748 'endDate' => $args['end_date']
749 )
750 );
751
752 $dimensions = array(
753 array( 'name' => 'date' ),
754 array( 'name' => 'hostName' )
755 );
756
757 $metrics = array(
758 array( 'name' => 'sessions' ),
759 array( 'name' => 'totalUsers' ),
760 array( 'name' => 'screenPageViews' ),
761 array( 'name' => 'bounceRate' )
762 );
763
764 $request_data = array(
765 'dateRanges' => $date_ranges,
766 'dimensions' => $dimensions,
767 'metrics' => $metrics,
768 'orderBys' => array(
769 array(
770 'dimension' => array( 'dimensionName' => 'date' ),
771 'desc' => false
772 )
773 )
774 );
775
776 $response = $this->make_google_analytics_request( 'runReport', $request_data );
777
778 if ( ! $response ) {
779 return array();
780 }
781
782 return $this->transform_analytics_data( $response, $args );
783 }
784
785 private function get_analytics_summary_from_api( $start_date, $end_date )
786 {
787 $date_ranges = array(
788 array(
789 'startDate' => $start_date,
790 'endDate' => $end_date
791 )
792 );
793
794 $dimensions = array(
795 array( 'name' => 'hostName' )
796 );
797
798 $metrics = array(
799 array( 'name' => 'sessions' ),
800 array( 'name' => 'totalUsers' ),
801 array( 'name' => 'screenPageViews' ),
802 array( 'name' => 'bounceRate' ),
803 array( 'name' => 'averageSessionDuration' ),
804 array( 'name' => 'screenPageViewsPerSession' )
805 );
806
807 $request_data = array(
808 'dateRanges' => $date_ranges,
809 'dimensions' => $dimensions,
810 'metrics' => $metrics
811 );
812
813 $response = $this->make_google_analytics_request( 'runReport', $request_data );
814
815 if ( ! $response ) {
816 return array();
817 }
818
819 return $this->transform_analytics_summary( $response );
820 }
821
822 private function get_top_posts_from_api( $args )
823 {
824 $date_ranges = array(
825 array(
826 'startDate' => $args['start_date'],
827 'endDate' => $args['end_date']
828 )
829 );
830
831 $dimensions = array(
832 array( 'name' => 'pagePath' ),
833 array( 'name' => 'pageTitle' ),
834 array( 'name' => 'country' ),
835 array( 'name' => 'hostName' )
836 );
837
838 $metrics = array(
839 array( 'name' => 'sessions' ),
840 array( 'name' => 'totalUsers' ),
841 array( 'name' => 'screenPageViews' ),
842 array( 'name' => 'averageSessionDuration' )
843 );
844
845 $request_data = array(
846 'dateRanges' => $date_ranges,
847 'dimensions' => $dimensions,
848 'metrics' => $metrics,
849 'orderBys' => array(
850 array(
851 'metric' => array( 'metricName' => 'sessions' ),
852 'desc' => true
853 )
854 ),
855
856 // We should display 10 posts, but if we sort them by country, we might need more to have like 10 per country
857 'limit' => 50
858 //'limit' => $args['limit']
859 );
860
861 $response = $this->make_google_analytics_request( 'runReport', $request_data );
862
863 if ( ! $response ) {
864 return array();
865 }
866
867 return $this->transform_top_posts_data( $response );
868 }
869
870 private function make_google_analytics_request( $endpoint, $data )
871 {
872 // Clear previous error
873 $this->last_api_error = null;
874
875 // Get access token
876 $access_token = $this->current_access_token;
877 if ( ! $access_token ) {
878 $this->last_api_error = 'Failed to obtain access token';
879 $this->core->log( "" . $this->last_api_error );
880 return false;
881 }
882
883 $url = $this->base_url . 'properties/' . $this->property_id . ':' . $endpoint;
884
885 $args = array(
886 'body' => json_encode( $data ),
887 'headers' => array(
888 'Content-Type' => 'application/json',
889 'Authorization' => 'Bearer ' . $access_token,
890 ),
891 'timeout' => 30
892 );
893
894 $response = wp_remote_post( $url, $args );
895
896 if ( is_wp_error( $response ) ) {
897 $this->last_api_error = $response->get_error_message();
898 $this->core->log( "❌ Google Analytics API Request Error: " . $this->last_api_error );
899 throw new Exception( $this->last_api_error );
900 }
901
902 $response_code = wp_remote_retrieve_response_code( $response );
903 $response_body = wp_remote_retrieve_body( $response );
904
905 if ( $response_code !== 200 ) {
906 // Parse error message from response body if available
907 $decoded_error = json_decode( $response_body, true );
908 if ( isset( $decoded_error['error']['message'] ) ) {
909 $this->last_api_error = $decoded_error['error']['message'];
910 } else {
911 $this->last_api_error = "HTTP Error " . $response_code;
912 }
913 $this->core->log( "❌ Google Analytics API HTTP Error: " . $response_code . " - " . $response_body );
914 throw new Exception( $this->last_api_error );
915 }
916
917 $decoded_response = json_decode( $response_body, true );
918
919 if ( json_last_error() !== JSON_ERROR_NONE ) {
920 $this->last_api_error = 'JSON Decode Error: ' . json_last_error_msg();
921 $this->core->log( "❌ Google Analytics API JSON Decode Error: " . json_last_error_msg() );
922 return false;
923 }
924
925 return $decoded_response;
926 }
927
928 // GA4 emits "(not set)" / empty hostName rows (cookieless hits, proxies, some spam). They
929 // carry no usable traffic, often show 100% bounce with 0 pageviews, and poison the "all"
930 // aggregate, so every transform below drops them.
931 private function is_noise_host( $host ) {
932 $host = strtolower( trim( (string) $host ) );
933 return $host === '' || $host === '(not set)' || $host === 'localhost';
934 }
935
936 private function transform_analytics_data( $response, $args ) {
937 $data = array();
938 $daily_totals = array();
939
940 if ( ! isset( $response['rows'] ) ) {
941 return $data;
942 }
943
944 // First, gather data for individual hosts and simultaneously calculate daily totals.
945 foreach ( $response['rows'] as $row ) {
946
947 $date_raw = $row['dimensionValues'][0]['value'];
948 $host = isset( $row['dimensionValues'][1]['value'] ) ? $row['dimensionValues'][1]['value'] : '';
949 if ( $this->is_noise_host( $host ) ) {
950 continue;
951 }
952
953 // The date is in YYYYMMDD format, convert it to YYYY-MM-DD
954 $year = substr( $date_raw, 0, 4 );
955 $month = substr( $date_raw, 4, 2 );
956 $day = substr( $date_raw, 6, 2 );
957 $date = $year . '-' . $month . '-' . $day;
958
959 $sessions = isset( $row['metricValues'][0]['value'] ) ? (int) $row['metricValues'][0]['value'] : 0;
960 $users = isset( $row['metricValues'][1]['value'] ) ? (int) $row['metricValues'][1]['value'] : 0;
961 $pageviews = isset( $row['metricValues'][2]['value'] ) ? (int) $row['metricValues'][2]['value'] : 0;
962 $bounce_rate = isset( $row['metricValues'][3]['value'] ) ? (float) $row['metricValues'][3]['value'] * 100 : 0;
963
964 // Add the specific host data
965 $data[] = array(
966 'date' => $date,
967 'visits' => $sessions,
968 'unique_visitors' => $users,
969 'pageviews' => $pageviews,
970 'bounce_rate' => round( $bounce_rate, 2 ),
971 'host' => $host
972 );
973
974 // Initialize daily total record if it doesn't exist
975 if ( ! isset( $daily_totals[ $date ] ) ) {
976 $daily_totals[ $date ] = array(
977 'visits' => 0,
978 'unique_visitors' => 0,
979 'pageviews' => 0,
980 'bounce_rate_sum' => 0,
981 'count' => 0,
982 );
983 }
984
985 // Aggregate data for the 'all' host summary
986 $daily_totals[ $date ]['visits'] += $sessions;
987 $daily_totals[ $date ]['unique_visitors'] += $users;
988 $daily_totals[ $date ]['pageviews'] += $pageviews;
989 $daily_totals[ $date ]['bounce_rate_sum'] += $bounce_rate;
990 $daily_totals[ $date ]['count']++;
991 }
992
993 // Now, create the 'all' host entries from the aggregated daily totals
994 foreach ( $daily_totals as $date => $totals ) {
995 $avg_bounce_rate = ( $totals['count'] > 0 ) ? $totals['bounce_rate_sum'] / $totals['count'] : 0;
996
997 $data[] = array(
998 'date' => $date,
999 'visits' => $totals['visits'],
1000 'unique_visitors' => $totals['unique_visitors'],
1001 'pageviews' => $totals['pageviews'],
1002 'bounce_rate' => round( $avg_bounce_rate, 2 ),
1003 'host' => 'all'
1004 );
1005 }
1006
1007 $group_by = $args['group_by'];
1008
1009 if ( $group_by === 'day' ) {
1010 // No grouping needed, data is already prepared
1011 } elseif ( $group_by === 'month' || $group_by === 'year' ) {
1012 $data = array_reduce( $data, function( $carry, $item ) use ( $group_by ) {
1013 $key = ( $group_by === 'month' ) ? date( 'Y-m', strtotime( $item['date'] ) ) : date( 'Y', strtotime( $item['date'] ) );
1014
1015 // We need to group by both the time key and the host
1016 $group_key = $key . '_' . $item['host'];
1017
1018 if ( ! isset( $carry[ $group_key ] ) ) {
1019 $carry[ $group_key ] = array(
1020 'date' => $item['date'], // Keep one date for display
1021 'visits' => 0,
1022 'unique_visitors' => 0,
1023 'pageviews' => 0,
1024 'bounce_rate_sum' => 0,
1025 'count' => 0,
1026 'host' => $item['host'],
1027 );
1028 }
1029
1030 // Sum the values for the period
1031 $carry[ $group_key ]['visits'] += $item['visits'];
1032 $carry[ $group_key ]['unique_visitors'] += $item['unique_visitors'];
1033 $carry[ $group_key ]['pageviews'] += $item['pageviews'];
1034 $carry[ $group_key ]['bounce_rate_sum'] += $item['bounce_rate'];
1035 $carry[ $group_key ]['count']++;
1036
1037 return $carry;
1038 }, array() );
1039
1040 // Calculate average bounce rate for each group and clean up the array
1041 $data = array_map(function( $item ) {
1042 $item['bounce_rate'] = ( $item['count'] > 0 ) ? round( $item['bounce_rate_sum'] / $item['count'], 2 ) : 0;
1043 unset( $item['bounce_rate_sum'], $item['count'] );
1044 return $item;
1045 }, $data);
1046
1047 $data = array_values( $data );
1048 }
1049
1050 return $data;
1051 }
1052
1053 private function transform_analytics_summary( $response )
1054 {
1055 if ( ! isset( $response['rows'][0] ) ) {
1056 return array();
1057 }
1058
1059 $rows_count = 0;
1060 $data = array();
1061 $all = array(
1062 'total_visits' => 0,
1063 'unique_visitors' => 0,
1064 'pageviews' => 0,
1065 'bounce_rate' => 0,
1066 'avg_session_duration' => 0,
1067 'pages_per_session' => 0,
1068
1069 'host' => 'all'
1070 );
1071
1072 foreach ( $response['rows'] as $row ) {
1073
1074 $host = isset( $row['dimensionValues'][0]['value'] ) ? $row['dimensionValues'][0]['value'] : '';
1075 if ( $this->is_noise_host( $host ) ) {
1076 continue;
1077 }
1078 $rows_count++;
1079
1080 $sessions = isset( $row['metricValues'][0]['value'] ) ? (int) $row['metricValues'][0]['value'] : 0;
1081 $users = isset( $row['metricValues'][1]['value'] ) ? (int) $row['metricValues'][1]['value'] : 0;
1082 $pageviews = isset( $row['metricValues'][2]['value'] ) ? (int) $row['metricValues'][2]['value'] : 0;
1083 $bounce_rate = isset( $row['metricValues'][3]['value'] ) ? (float) $row['metricValues'][3]['value'] * 100 : 0;
1084 $avg_session_duration = isset( $row['metricValues'][4]['value'] ) ? (float) $row['metricValues'][4]['value'] : 0;
1085 $pages_per_session = isset( $row['metricValues'][5]['value'] ) ? (float) $row['metricValues'][5]['value'] : 0;
1086
1087 $values = array(
1088 'total_visits' => $sessions,
1089 'unique_visitors' => $users,
1090 'pageviews' => $pageviews,
1091 'bounce_rate' => round( $bounce_rate, 2 ),
1092 'avg_session_duration' => round( $avg_session_duration ),
1093 'pages_per_session' => round( $pages_per_session, 2 ),
1094 'host' => $host
1095 );
1096
1097 $data[] = $values;
1098
1099 $all['total_visits'] += $sessions;
1100 $all['unique_visitors'] += $users;
1101 $all['pageviews'] += $pageviews;
1102
1103 // We need to average the bounce rate and session duration
1104 $all['bounce_rate'] += $bounce_rate;
1105 $all['avg_session_duration'] += $avg_session_duration;
1106 $all['pages_per_session'] += $pages_per_session;
1107
1108 }
1109
1110 // Averae the cumulative values
1111 if ( $rows_count > 0 ) {
1112 $all['bounce_rate'] = round( $all['bounce_rate'] / $rows_count, 2 );
1113 $all['avg_session_duration'] = round( $all['avg_session_duration'] / $rows_count );
1114 $all['pages_per_session'] = round( $all['pages_per_session'] / $rows_count, 2 );
1115 }
1116
1117 // We add the overall summary
1118 $data[] = $all;
1119
1120 return $data;
1121 }
1122
1123 private function transform_top_posts_data( $response )
1124 {
1125 global $wpdb;
1126 $data = array();
1127
1128 if ( ! isset( $response['rows'] ) ) {
1129 return $data;
1130 }
1131
1132 foreach ( $response['rows'] as $row ) {
1133
1134 // Dimensions
1135 $page_path = $row['dimensionValues'][0]['value'];
1136 $page_title = $row['dimensionValues'][1]['value'];
1137 $country = isset( $row['dimensionValues'][2]['value'] ) ? $row['dimensionValues'][2]['value'] : '';
1138 $host = isset( $row['dimensionValues'][3]['value'] ) ? $row['dimensionValues'][3]['value'] : '';
1139 if ( $this->is_noise_host( $host ) ) {
1140 continue;
1141 }
1142
1143 // Metrics
1144 $sessions = isset( $row['metricValues'][0]['value'] ) ? (int) $row['metricValues'][0]['value'] : 0;
1145 $users = isset( $row['metricValues'][1]['value'] ) ? (int) $row['metricValues'][1]['value'] : 0;
1146 $pageviews = isset( $row['metricValues'][2]['value'] ) ? (int) $row['metricValues'][2]['value'] : 0;
1147 $avg_session_duration = isset( $row['metricValues'][3]['value'] ) ? (float) $row['metricValues'][3]['value'] : 0;
1148
1149 // Try to match with WordPress posts
1150 $post_link = $host ? 'https://' . $host . $page_path : '';
1151 $post_id = url_to_postid( $post_link ) ?: 0;
1152 $post_type = get_post( $post_id ) ? get_post_type( $post_id ) : 'page';
1153
1154 $data[] = array(
1155 'ID' => $post_id,
1156 'post_title' => $page_title,
1157 'post_type' => $post_type,
1158 'visits' => $sessions,
1159 'unique_visitors' => $users,
1160 'pageviews' => $pageviews,
1161 'avg_time_on_page' => round( $avg_session_duration ),
1162 'page_path' => $page_path,
1163 'post_link' => $post_link ?? home_url( $page_path ),
1164 'country' => $country,
1165 'host' => $host
1166 );
1167 }
1168
1169 return $data;
1170 }
1171
1172 // Realtime Analytics method
1173 public function get_realtime_data()
1174 {
1175 if ( !$this->is_authenticated() ) {
1176 return array();
1177 }
1178
1179 // Check if there is cached version of the report
1180 if ( $this->use_cache ) {
1181 $transient_key = self::TRANSIENT_REPORT_PREFIX . 'realtime_data' . $this->property_id;
1182 $cached_report = get_transient( $transient_key );
1183 if ( $cached_report !== false ) {
1184 return $cached_report;
1185 }
1186 }
1187
1188 try {
1189 $metrics = array(
1190 array( 'name' => 'activeUsers' )
1191 );
1192
1193 $request_data = array(
1194 'metrics' => $metrics
1195 );
1196
1197 $response = $this->make_realtime_request( $request_data );
1198
1199 if ( ! $response ) {
1200 return array();
1201 }
1202
1203 $active_users = 0;
1204 if ( isset( $response['rows'][0]['metricValues'][0]['value'] ) ) {
1205 $active_users = (int) $response['rows'][0]['metricValues'][0]['value'];
1206 }
1207
1208 $result = array(
1209 'active_users' => $active_users
1210 );
1211
1212 // Cache
1213 if ( ! empty( $result ) && $this->use_cache ) {
1214 set_transient( $transient_key, $result, 5 * MINUTE_IN_SECONDS );
1215 }
1216
1217 return $result;
1218
1219 } catch ( Exception $e ) {
1220 $this->core->log( "❌ Google Analytics Realtime API Error: " . $e->getMessage() );
1221 return array();
1222 }
1223 }
1224
1225 private function make_realtime_request( $data )
1226 {
1227 // Get access token
1228 $access_token = $this->current_access_token;
1229 if ( ! $access_token ) {
1230 $this->core->log( "❌ Failed to obtain access token for realtime request" );
1231 return false;
1232 }
1233
1234 if ( ! isset( $this->property_id ) || empty( $this->property_id ) ) {
1235 $this->core->log( "❌ Property ID is not set for realtime request" );
1236 return false;
1237 }
1238
1239 $url = $this->realtime_url . 'properties/' . $this->property_id . ':runRealtimeReport';
1240
1241 $args = array(
1242 'body' => json_encode( $data ),
1243 'headers' => array(
1244 'Content-Type' => 'application/json',
1245 'Authorization' => 'Bearer ' . $access_token,
1246 ),
1247 'timeout' => 30
1248 );
1249
1250 $response = wp_remote_post( $url, $args );
1251
1252 if ( is_wp_error( $response ) ) {
1253 $error_message = $response->get_error_message();
1254 $this->core->log( "❌ Google Analytics Realtime API Request Error: " . $error_message );
1255 throw new Exception( $error_message );
1256 }
1257
1258 $response_code = wp_remote_retrieve_response_code( $response );
1259 $response_body = wp_remote_retrieve_body( $response );
1260
1261 if ( $response_code !== 200 ) {
1262 // Parse error message from response body if available
1263 $decoded_error = json_decode( $response_body, true );
1264 if ( isset( $decoded_error['error']['message'] ) ) {
1265 $error_message = $decoded_error['error']['message'];
1266 } else {
1267 $error_message = "HTTP Error " . $response_code;
1268 }
1269 $this->core->log( "❌ Google Analytics Realtime API HTTP Error: " . $response_code . " - " . $response_body );
1270 throw new Exception( $error_message );
1271 }
1272
1273 $decoded_response = json_decode( $response_body, true );
1274
1275 if ( json_last_error() !== JSON_ERROR_NONE ) {
1276 $error_message = 'JSON Decode Error: ' . json_last_error_msg();
1277 $this->core->log( "❌ Google Analytics Realtime API JSON Decode Error: " . json_last_error_msg() );
1278 throw new Exception( $error_message );
1279 }
1280
1281 return $decoded_response;
1282 }
1283
1284
1285 #endregion
1286
1287 }
1288