PluginProbe
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress / trunk
SEO Engine – Smart SEO with AI, Schema & Redirection for WordPress vtrunk
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 / matomoanalytics.php

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

613 lines 18.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_MatomoAnalytics
5 {
6 private $core = null;
7 private $api_key = null;
8 private $server_url = null;
9 private $site_id = null;
10 private $use_cache = false;
11 private $disabled_tracking = false;
12
13 const TRANSIENT_REPORT_PREFIX = 'mwseo_matomo_analytics_report_';
14
15 // Matomo returns every page of a report unless capped, and the default row carries a fat
16 // goals/segment payload: an uncapped 30-day page-day matrix measures in megabytes, which
17 // these reports have to survive on 128M hosts. So every call trims columns and either
18 // scopes to known paths or caps the row count.
19 const MAX_ROWS_DAILY = 400;
20 const MAX_ROWS_TOTALS = 1000;
21
22 // Longest path list we'll turn into a filter regex before falling back to a capped report.
23 const MAX_SCOPED_PATHS = 200;
24
25 const COLUMNS_BASIC = 'label,nb_visits,nb_hits,url';
26 const COLUMNS_FULL = 'label,nb_visits,nb_hits,url,bounce_rate,avg_time_on_page';
27
28 public function __construct( $core )
29 {
30 $this->core = $core;
31 $this->init();
32 $this->tracking();
33 }
34
35 /**
36 * Initialize the module and load settings.
37 */
38 public function init()
39 {
40 $this->api_key = $this->core->get_option( 'matomo_api_key', '' );
41 $this->server_url = $this->core->get_option( 'matomo_server_url', '' );
42 $this->site_id = $this->core->get_option( 'matomo_site_id', '' );
43 $this->use_cache = $this->core->get_option( 'analytics_cache', false );
44
45 // Accept both "https://analytics.example.com" and ".../index.php" as the server URL.
46 $this->server_url = rtrim( $this->server_url, '/' );
47 $this->server_url = preg_replace( '#/index\.php$#', '', $this->server_url );
48 }
49
50 /**
51 * Initialize tracking script injection.
52 */
53 public function tracking() {
54
55 if ( is_admin() ) { return; }
56
57 $this->disabled_tracking = $this->core->get_option( 'matomo_analytics_tracking_disabled', false );
58
59 if ( $this->disabled_tracking || empty( $this->site_id ) || empty( $this->server_url ) ) {
60 return;
61 }
62
63 add_action( 'wp_enqueue_scripts', array( $this, 'wp_enqueue_tracking_scripts' ) );
64 }
65
66 public function wp_enqueue_tracking_scripts() {
67
68 // Don't track logged-in users
69 if ( is_user_logged_in() ) {
70 $track_logged_users = $this->core->get_option( 'matomo_track_logged_users', false );
71 if ( !$track_logged_users ) { return; }
72
73 // Don't track editors and admins
74 $is_power_user = current_user_can( 'editor' ) || current_user_can( 'administrator' );
75 if ( $is_power_user ) {
76 $track_power_users = $this->core->get_option( 'matomo_track_power_users', false );
77 if ( !$track_power_users ) { return; }
78 }
79 }
80
81 wp_register_script( 'mwseo-analytics-matomo', $this->server_url . '/matomo.js', array(), null, true );
82 wp_enqueue_script( 'mwseo-analytics-matomo' );
83
84 // matomo.js reads the _paq queue, so it has to exist before the script runs.
85 $bootstrap = sprintf(
86 "var _paq = window._paq = window._paq || [];\n" .
87 "_paq.push(['trackPageView']);\n" .
88 "_paq.push(['enableLinkTracking']);\n" .
89 "_paq.push(['setTrackerUrl', %s]);\n" .
90 "_paq.push(['setSiteId', %s]);",
91 wp_json_encode( $this->server_url . '/matomo.php' ),
92 wp_json_encode( (string) $this->site_id )
93 );
94 wp_add_inline_script( 'mwseo-analytics-matomo', $bootstrap, 'before' );
95 }
96
97 /**
98 * Check if Matomo Analytics is configured.
99 */
100 public function is_configured() {
101 return ! empty( $this->api_key ) && ! empty( $this->site_id ) && ! empty( $this->server_url );
102 }
103
104 /**
105 * Make a Reporting API request to Matomo.
106 */
107 private function make_request( $method, $params = array() ) {
108 if ( ! $this->is_configured() ) {
109 throw new Exception( 'Matomo Analytics is not configured.' );
110 }
111
112 $params = array_merge( array(
113 'module' => 'API',
114 'format' => 'JSON',
115 'method' => $method,
116 'idSite' => $this->site_id,
117 ), $params );
118
119 // Matomo 4+ rejects token_auth in the query string, it has to be POSTed.
120 $params['token_auth'] = $this->api_key;
121
122 $response = wp_remote_post( $this->server_url . '/index.php', array(
123 'body' => $params,
124 'timeout' => 30,
125 ) );
126
127 if ( is_wp_error( $response ) ) {
128 throw new Exception( 'Matomo API request failed: ' . $response->get_error_message() );
129 }
130
131 $code = wp_remote_retrieve_response_code( $response );
132 $body = wp_remote_retrieve_body( $response );
133
134 if ( $code !== 200 ) {
135 throw new Exception( 'Matomo API returned status code: ' . $code );
136 }
137
138 $data = json_decode( $body, true );
139
140 if ( json_last_error() !== JSON_ERROR_NONE ) {
141 throw new Exception( 'Failed to parse Matomo API response.' );
142 }
143
144 // Matomo answers errors with HTTP 200 and a JSON body instead of a status code.
145 if ( isset( $data['result'] ) && $data['result'] === 'error' ) {
146 $message = isset( $data['message'] ) ? $data['message'] : 'Unknown error.';
147 throw new Exception( 'Matomo API error: ' . $message );
148 }
149
150 return $data;
151 }
152
153 /**
154 * Get analytics data for a time series.
155 */
156 public function get_data( $args = array() ) {
157 $start_date = isset( $args['start_date'] ) && $args['start_date'] ? $args['start_date'] : date( 'Y-m-d', strtotime( '-30 days' ) );
158 $end_date = isset( $args['end_date'] ) && $args['end_date'] ? $args['end_date'] : date( 'Y-m-d' );
159 $period = isset( $args['group_by'] ) ? $this->convert_group_by_to_period( $args['group_by'] ) : 'day';
160
161 $cache_key = self::TRANSIENT_REPORT_PREFIX . md5( $start_date . $end_date . $period );
162
163 if ( $this->use_cache ) {
164 $cached_data = get_transient( $cache_key );
165 if ( $cached_data !== false ) {
166 return $cached_data;
167 }
168 }
169
170 try {
171 // With a date range and a sub-range period, Matomo returns one entry per period,
172 // keyed by the period's date.
173 $data = $this->make_request( 'VisitsSummary.get', array(
174 'period' => $period,
175 'date' => $start_date . ',' . $end_date,
176 ) );
177
178 $transformed = array();
179
180 foreach ( (array) $data as $date => $row ) {
181 if ( !is_array( $row ) ) { continue; }
182
183 $transformed[] = array(
184 'period' => $date,
185 'visits' => isset( $row['nb_visits'] ) ? (int) $row['nb_visits'] : 0,
186 'unique_visitors' => $this->unique_visitors( $row ),
187 'unique_posts' => 0, // Not tracked by Matomo
188 'bounce_rate' => $this->parse_percentage( isset( $row['bounce_rate'] ) ? $row['bounce_rate'] : 0 ),
189 'visit_duration' => isset( $row['avg_time_on_site'] ) ? (int) $row['avg_time_on_site'] : 0,
190 );
191 }
192
193 if ( $this->use_cache ) {
194 set_transient( $cache_key, $transformed, 12 * HOUR_IN_SECONDS );
195 }
196
197 return $transformed;
198
199 } catch ( Exception $e ) {
200 return array();
201 }
202 }
203
204 /**
205 * Get analytics summary.
206 */
207 public function get_summary( $start_date = null, $end_date = null ) {
208 if ( ! $start_date ) {
209 $start_date = date( 'Y-m-d', strtotime( '-30 days' ) );
210 }
211 if ( ! $end_date ) {
212 $end_date = date( 'Y-m-d' );
213 }
214
215 $cache_key = self::TRANSIENT_REPORT_PREFIX . 'summary_' . md5( $start_date . $end_date );
216
217 if ( $this->use_cache ) {
218 $cached_data = get_transient( $cache_key );
219 if ( $cached_data !== false ) {
220 return $cached_data;
221 }
222 }
223
224 try {
225 $data = $this->make_request( 'VisitsSummary.get', array(
226 'period' => 'range',
227 'date' => $start_date . ',' . $end_date,
228 ) );
229
230 // nb_actions also counts downloads, outlinks and site searches, so real page views
231 // come from Actions.get. Falls back to nb_actions if that report is unavailable.
232 $pageviews = isset( $data['nb_actions'] ) ? (int) $data['nb_actions'] : 0;
233 try {
234 $actions = $this->make_request( 'Actions.get', array(
235 'period' => 'range',
236 'date' => $start_date . ',' . $end_date,
237 ) );
238 if ( isset( $actions['nb_pageviews'] ) ) {
239 $pageviews = (int) $actions['nb_pageviews'];
240 }
241 } catch ( Exception $e ) {
242 // Keep the nb_actions estimate.
243 }
244
245 $summary = array(
246 'total_visits' => isset( $data['nb_visits'] ) ? (int) $data['nb_visits'] : 0,
247 'unique_visitors' => $this->unique_visitors( $data ),
248 'unique_posts' => 0, // Matomo doesn't provide this
249 'logged_in_visits' => 0, // Matomo doesn't track this
250 'bounce_rate' => $this->parse_percentage( isset( $data['bounce_rate'] ) ? $data['bounce_rate'] : 0 ),
251 'pageviews' => $pageviews,
252 'views_per_visit' => isset( $data['nb_actions_per_visit'] ) ? (float) $data['nb_actions_per_visit'] : 0,
253 'visit_duration' => isset( $data['avg_time_on_site'] ) ? (int) $data['avg_time_on_site'] : 0,
254 );
255
256 if ( $this->use_cache ) {
257 set_transient( $cache_key, $summary, 12 * HOUR_IN_SECONDS );
258 }
259
260 return $summary;
261
262 } catch ( Exception $e ) {
263 return array(
264 'total_visits' => 0,
265 'unique_visitors' => 0,
266 'unique_posts' => 0,
267 'logged_in_visits' => 0,
268 'bounce_rate' => 0,
269 'pageviews' => 0,
270 'views_per_visit' => 0,
271 'visit_duration' => 0,
272 );
273 }
274 }
275
276 /**
277 * Visitors seen in the last few minutes. Needs the Live plugin, which is bundled and
278 * enabled by default in Matomo, so a failure here just hides the card.
279 */
280 public function get_realtime_data() {
281 if ( ! $this->is_configured() ) {
282 return array();
283 }
284
285 try {
286 $data = $this->make_request( 'Live.getCounters', array( 'lastMinutes' => 5 ) );
287 $row = isset( $data[0] ) && is_array( $data[0] ) ? $data[0] : array();
288
289 return array(
290 'active_users' => isset( $row['visitors'] ) ? (int) $row['visitors'] : 0,
291 );
292 } catch ( Exception $e ) {
293 return array();
294 }
295 }
296
297 /**
298 * Get the stats of a single page.
299 */
300 public function get_post_analytics( $page_path, $start_date = null, $end_date = null ) {
301 if ( ! $start_date ) {
302 $start_date = date( 'Y-m-d', strtotime( '-30 days' ) );
303 }
304 if ( ! $end_date ) {
305 $end_date = date( 'Y-m-d' );
306 }
307
308 try {
309 // Both slash variants: Matomo stores the path as it was requested.
310 $no_slash = rtrim( $page_path, '/' );
311 if ( $no_slash === '' ) { $no_slash = '/'; }
312 $variants = $no_slash === '/' ? array( '/' ) : array( $no_slash, $no_slash . '/' );
313
314 $rows = $this->request_page_urls( 'range', $start_date . ',' . $end_date, array(
315 'columns' => self::COLUMNS_FULL,
316 'paths' => $variants,
317 ) );
318 $wanted = $this->normalize_path( $page_path );
319
320 foreach ( $rows as $row ) {
321 if ( $this->normalize_path( $this->row_path( $row ) ) !== $wanted ) { continue; }
322
323 return array(
324 'visits' => isset( $row['nb_hits'] ) ? (int) $row['nb_hits'] : 0,
325 'unique_visitors' => isset( $row['nb_visits'] ) ? (int) $row['nb_visits'] : 0,
326 'pageviews' => isset( $row['nb_hits'] ) ? (int) $row['nb_hits'] : 0,
327 'bounce_rate' => $this->parse_percentage( isset( $row['bounce_rate'] ) ? $row['bounce_rate'] : 0 ),
328 'avg_time_on_page' => isset( $row['avg_time_on_page'] ) ? (int) $row['avg_time_on_page'] : 0,
329 'page_path' => $page_path
330 );
331 }
332
333 return array();
334 } catch ( Exception $e ) {
335 return array();
336 }
337 }
338
339 /**
340 * Get top posts/pages.
341 */
342 public function get_top_posts( $args = array() ) {
343 $defaults = array( 'start_date' => null, 'end_date' => null, 'limit' => 10 );
344 $args = wp_parse_args( $args, $defaults );
345
346 $start_date = $args['start_date'] ? $args['start_date'] : date( 'Y-m-d', strtotime( '-30 days' ) );
347 $end_date = $args['end_date'] ? $args['end_date'] : date( 'Y-m-d' );
348 $limit = (int) $args['limit'];
349
350 $cache_key = self::TRANSIENT_REPORT_PREFIX . 'top_posts_' . md5( $start_date . $end_date . $limit );
351
352 if ( $this->use_cache ) {
353 $cached_data = get_transient( $cache_key );
354 if ( $cached_data !== false ) {
355 return $cached_data;
356 }
357 }
358
359 try {
360 $rows = $this->request_page_urls( 'range', $start_date . ',' . $end_date, array(
361 'limit' => $limit,
362 'columns' => self::COLUMNS_FULL,
363 ) );
364
365 $top_posts = array();
366
367 foreach ( $rows as $row ) {
368 $page_path = $this->row_path( $row );
369 if ( $page_path === '' ) { continue; }
370
371 $post_id = url_to_postid( home_url( $page_path ) );
372
373 $post_title = 'Untitled';
374 $post_url = home_url( $page_path );
375 $post_type = 'page';
376
377 if ( $post_id > 0 ) {
378 $post = get_post( $post_id );
379 if ( $post ) {
380 $post_title = $post->post_title;
381 $post_url = get_permalink( $post_id );
382 $post_type = $post->post_type;
383 }
384 }
385
386 $top_posts[] = array(
387 'post_id' => $post_id,
388 'post_title' => $post_title,
389 'post_url' => $post_url,
390 'post_type' => $post_type,
391 'path' => $page_path,
392 'visits' => isset( $row['nb_hits'] ) ? (int) $row['nb_hits'] : 0,
393 'unique_visitors' => isset( $row['nb_visits'] ) ? (int) $row['nb_visits'] : 0,
394 'bounce_rate' => $this->parse_percentage( isset( $row['bounce_rate'] ) ? $row['bounce_rate'] : 0 ),
395 'visit_duration' => isset( $row['avg_time_on_page'] ) ? (int) $row['avg_time_on_page'] : 0,
396 );
397 }
398
399 if ( $this->use_cache ) {
400 set_transient( $cache_key, $top_posts, 12 * HOUR_IN_SECONDS );
401 }
402
403 return $top_posts;
404
405 } catch ( Exception $e ) {
406 return array();
407 }
408 }
409
410 /**
411 * Per-day, per-page visitors, for the Content SEO sparklines.
412 * Returns [ [ 'date' => Y-m-d, 'host' => ..., 'path' => ..., 'visitors' => int ], ... ].
413 * Scoped to $paths when given: the unscoped page-day matrix is megabytes on a busy site.
414 */
415 public function get_pages_daily( $start_date = null, $end_date = null, $paths = null ) {
416 if ( ! $start_date ) { $start_date = date( 'Y-m-d', strtotime( '-30 days' ) ); }
417 if ( ! $end_date ) { $end_date = date( 'Y-m-d' ); }
418
419 try {
420 // period=day over a range gives one bucket of rows per day, keyed by date.
421 $data = $this->request_page_urls( 'day', $start_date . ',' . $end_date, array(
422 'limit' => self::MAX_ROWS_DAILY,
423 'columns' => self::COLUMNS_BASIC,
424 'paths' => $paths,
425 ) );
426
427 $out = array();
428
429 foreach ( (array) $data as $date => $rows ) {
430 if ( !is_array( $rows ) ) { continue; }
431 foreach ( $rows as $row ) {
432 if ( !is_array( $row ) ) { continue; }
433 $path = $this->row_path( $row );
434 if ( $path === '' ) { continue; }
435 $out[] = array(
436 'date' => $date,
437 'host' => $this->row_host( $row ),
438 'path' => $path,
439 'visitors' => isset( $row['nb_visits'] ) ? (int) $row['nb_visits'] : 0,
440 );
441 }
442 }
443
444 return $out;
445 } catch ( Exception $e ) {
446 return array();
447 }
448 }
449
450 /**
451 * Per-page visitor totals over the window (no date dimension, so one row per page).
452 * Returns [ [ 'host' => ..., 'path' => ..., 'visitors' => int ], ... ].
453 */
454 public function get_pages_totals( $start_date = null, $end_date = null ) {
455 if ( ! $start_date ) { $start_date = date( 'Y-m-d', strtotime( '-30 days' ) ); }
456 if ( ! $end_date ) { $end_date = date( 'Y-m-d' ); }
457
458 try {
459 $rows = $this->request_page_urls( 'range', $start_date . ',' . $end_date, array(
460 'limit' => self::MAX_ROWS_TOTALS,
461 'columns' => self::COLUMNS_BASIC,
462 ) );
463
464 $out = array();
465
466 foreach ( $rows as $row ) {
467 $path = $this->row_path( $row );
468 if ( $path === '' ) { continue; }
469 $out[] = array(
470 'host' => $this->row_host( $row ),
471 'path' => $path,
472 'visitors' => isset( $row['nb_visits'] ) ? (int) $row['nb_visits'] : 0,
473 );
474 }
475
476 return $out;
477 } catch ( Exception $e ) {
478 return array();
479 }
480 }
481
482 /**
483 * One flat page-URL report. Flat mode returns every page as its own row instead of the
484 * folder tree Matomo uses by default. $args: limit, columns, paths.
485 */
486 private function request_page_urls( $period, $date, $args = array() ) {
487 $params = array(
488 'period' => $period,
489 'date' => $date,
490 'flat' => 1,
491 'filter_sort_column' => 'nb_visits',
492 'filter_sort_order' => 'desc',
493 );
494
495 if ( !empty( $args['columns'] ) ) {
496 $params['showColumns'] = $args['columns'];
497 }
498
499 // Scoping to the paths we actually care about is both lighter and more accurate than
500 // capping, since a low-traffic page can fall outside the top rows on a given day.
501 $regex = $this->build_label_filter( isset( $args['paths'] ) ? $args['paths'] : null );
502 if ( $regex !== null ) {
503 $params['filter_column'] = 'label';
504 $params['filter_pattern'] = $regex;
505 $params['filter_limit'] = -1;
506 }
507 else {
508 $params['filter_limit'] = isset( $args['limit'] ) ? (int) $args['limit'] : self::MAX_ROWS_TOTALS;
509 }
510
511 $data = $this->make_request( 'Actions.getPageUrls', $params );
512
513 return is_array( $data ) ? $data : array();
514 }
515
516 /**
517 * Anchored alternation over the given paths, matched against the flat row labels.
518 * Slashes are deliberately left unescaped: Matomo escapes them itself before building
519 * the regex, and pre-escaping them would produce a literal backslash instead.
520 */
521 private function build_label_filter( $paths ) {
522 if ( empty( $paths ) || !is_array( $paths ) ) { return null; }
523
524 $paths = array_values( array_unique( array_filter( $paths ) ) );
525 if ( empty( $paths ) || count( $paths ) > self::MAX_SCOPED_PATHS ) { return null; }
526
527 $quoted = array();
528 foreach ( $paths as $path ) {
529 $quoted[] = preg_quote( $path );
530 }
531
532 return '^(?:' . implode( '|', $quoted ) . ')$';
533 }
534
535 /**
536 * The path of a page row. Matomo gives a full `url` on most rows, and the flat `label`
537 * (already a path) otherwise.
538 */
539 private function row_path( $row ) {
540 if ( !empty( $row['url'] ) ) {
541 $path = parse_url( $row['url'], PHP_URL_PATH );
542 if ( $path ) { return $path; }
543 }
544 if ( !empty( $row['label'] ) ) {
545 return '/' . ltrim( $row['label'], '/' );
546 }
547 return '';
548 }
549
550 private function row_host( $row ) {
551 if ( !empty( $row['url'] ) ) {
552 $host = parse_url( $row['url'], PHP_URL_HOST );
553 if ( $host ) { return $host; }
554 }
555 return '*';
556 }
557
558 private function normalize_path( $path ) {
559 $path = (string) $path;
560 $q = strpos( $path, '?' );
561 if ( $q !== false ) { $path = substr( $path, 0, $q ); }
562 return strtolower( '/' . trim( $path, '/' ) );
563 }
564
565 /**
566 * Matomo reports rates as strings like "45%".
567 */
568 private function parse_percentage( $value ) {
569 if ( is_string( $value ) ) {
570 return (float) rtrim( trim( $value ), '%' );
571 }
572 return (float) $value;
573 }
574
575 /**
576 * Unique visitors are not processed for `range` periods unless the Matomo admin enabled
577 * it, so fall back to visits rather than reporting zero.
578 */
579 private function unique_visitors( $row ) {
580 if ( isset( $row['nb_uniq_visitors'] ) ) {
581 return (int) $row['nb_uniq_visitors'];
582 }
583 return isset( $row['nb_visits'] ) ? (int) $row['nb_visits'] : 0;
584 }
585
586 /**
587 * Convert our group_by format to Matomo's period format.
588 */
589 private function convert_group_by_to_period( $group_by ) {
590 $map = array(
591 'day' => 'day',
592 'week' => 'week',
593 'month' => 'month',
594 'year' => 'year',
595 );
596
597 return isset( $map[$group_by] ) ? $map[$group_by] : 'day';
598 }
599
600 /**
601 * Clear all cached reports.
602 */
603 public function clear_cache() {
604 global $wpdb;
605
606 $pattern = '_transient_' . self::TRANSIENT_REPORT_PREFIX . '%';
607 $wpdb->query( $wpdb->prepare(
608 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
609 $pattern
610 ) );
611 }
612 }
613