Changeset 3463996
- Timestamp:
- 02/18/2026 07:07:10 AM (6 weeks ago)
- Location:
- sfr-analytics
- Files:
-
- 29 added
- 8 edited
-
tags/0.6.0 (added)
-
tags/0.6.0/admin (added)
-
tags/0.6.0/admin/views (added)
-
tags/0.6.0/admin/views/campaigns.php (added)
-
tags/0.6.0/admin/views/dashboard.php (added)
-
tags/0.6.0/admin/views/settings.php (added)
-
tags/0.6.0/assets (added)
-
tags/0.6.0/assets/css (added)
-
tags/0.6.0/assets/css/admin.css (added)
-
tags/0.6.0/assets/js (added)
-
tags/0.6.0/assets/js/admin.js (added)
-
tags/0.6.0/assets/js/campaigns.js (added)
-
tags/0.6.0/assets/vendor (added)
-
tags/0.6.0/assets/vendor/chart-js (added)
-
tags/0.6.0/assets/vendor/chart-js/chart.min.js (added)
-
tags/0.6.0/docs (added)
-
tags/0.6.0/docs/SETTINGS_GUIDE.md (added)
-
tags/0.6.0/includes (added)
-
tags/0.6.0/includes/class-sfran-admin.php (added)
-
tags/0.6.0/includes/class-sfran-assets.php (added)
-
tags/0.6.0/includes/class-sfran-conflict-check.php (added)
-
tags/0.6.0/includes/class-sfran-rest-api.php (added)
-
tags/0.6.0/includes/class-sfran-session.php (added)
-
tags/0.6.0/includes/class-sfran-tracker.php (added)
-
tags/0.6.0/includes/functions.php (added)
-
tags/0.6.0/languages (added)
-
tags/0.6.0/readme.txt (added)
-
tags/0.6.0/sfr-analytics.php (added)
-
tags/0.6.0/uninstall.php (added)
-
trunk/admin/views/dashboard.php (modified) (5 diffs)
-
trunk/admin/views/settings.php (modified) (2 diffs)
-
trunk/assets/js/admin.js (modified) (1 diff)
-
trunk/includes/class-sfran-rest-api.php (modified) (2 diffs)
-
trunk/includes/class-sfran-tracker.php (modified) (10 diffs)
-
trunk/includes/functions.php (modified) (4 diffs)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/sfr-analytics.php (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
sfr-analytics/trunk/admin/views/dashboard.php
r3463796 r3463996 27 27 <div class="sfran-date-presets"> 28 28 <button type="button" class="button sfran-preset-btn" data-preset="today"><?php esc_html_e('Today', 'sfr-analytics'); ?></button> 29 <button type="button" class="button sfran-preset-btn" data-preset="last24h"><?php esc_html_e('Last 24 Hours', 'sfr-analytics'); ?></button> 29 30 <button type="button" class="button sfran-preset-btn" data-preset="last7"><?php esc_html_e('Last 7 Days', 'sfr-analytics'); ?></button> 30 31 <button type="button" class="button sfran-preset-btn" data-preset="last30"><?php esc_html_e('Last 30 Days', 'sfr-analytics'); ?></button> … … 208 209 foreach ($sfran_traffic_sources as $sfran_source): 209 210 $sfran_percentage = $sfran_total_visits > 0 ? round(($sfran_source['visits'] / $sfran_total_visits) * 100, 1) : 0; 211 $sfran_src_icon = sfran_traffic_source_icon( $sfran_source['type'] ); 210 212 ?> 211 213 <tr> 212 <td><s trong><?php echo esc_html($sfran_source['type']); ?></strong></td>214 <td><span class="dashicons <?php echo esc_attr( $sfran_src_icon ); ?>" style="font-size: 16px; width: 16px; height: 16px; margin-right: 6px; vertical-align: middle; color: #2271b1;"></span><strong><?php echo esc_html($sfran_source['type']); ?></strong></td> 213 215 <td><?php echo number_format($sfran_source['visits']); ?></td> 214 216 <td><?php echo number_format($sfran_source['visitors']); ?></td> … … 317 319 echo $sfran_is_hidden ? 'display: none;' : ''; 318 320 ?>"> 319 <p class="description" style="margin-bottom: 10px;"><?php esc_html_e('Individual websites that sent traffic to your site. ', 'sfr-analytics'); ?></p>321 <p class="description" style="margin-bottom: 10px;"><?php esc_html_e('Individual websites that sent traffic to your site. Direct visitors (no referrer) are not listed here — see Traffic Sources above for the full breakdown.', 'sfr-analytics'); ?></p> 320 322 <table class="wp-list-table widefat fixed striped"> 321 323 <thead> … … 333 335 foreach ($sfran_top_referrers as $sfran_ref): 334 336 $sfran_bar_width = $sfran_max_ref_views > 0 ? round(($sfran_ref['views'] / $sfran_max_ref_views) * 100) : 0; 337 $sfran_ref_icon = 'dashicons-admin-site-alt3'; 338 $sfran_ref_domain_lower = strtolower( $sfran_ref['domain'] ); 339 if ( false !== strpos( $sfran_ref_domain_lower, 'google' ) ) { 340 $sfran_ref_icon = 'dashicons-google'; 341 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'facebook' ) || false !== strpos( $sfran_ref_domain_lower, 'fb.com' ) ) { 342 $sfran_ref_icon = 'dashicons-facebook-alt'; 343 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'twitter' ) || false !== strpos( $sfran_ref_domain_lower, 'x.com' ) || false !== strpos( $sfran_ref_domain_lower, 't.co' ) ) { 344 $sfran_ref_icon = 'dashicons-twitter'; 345 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'instagram' ) ) { 346 $sfran_ref_icon = 'dashicons-instagram'; 347 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'youtube' ) ) { 348 $sfran_ref_icon = 'dashicons-video-alt3'; 349 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'linkedin' ) ) { 350 $sfran_ref_icon = 'dashicons-linkedin'; 351 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'reddit' ) ) { 352 $sfran_ref_icon = 'dashicons-reddit'; 353 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'pinterest' ) ) { 354 $sfran_ref_icon = 'dashicons-pinterest'; 355 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'bing' ) ) { 356 $sfran_ref_icon = 'dashicons-search'; 357 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'yahoo' ) ) { 358 $sfran_ref_icon = 'dashicons-search'; 359 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'duckduckgo' ) ) { 360 $sfran_ref_icon = 'dashicons-search'; 361 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'mail' ) || false !== strpos( $sfran_ref_domain_lower, 'outlook' ) ) { 362 $sfran_ref_icon = 'dashicons-email-alt'; 363 } elseif ( false !== strpos( $sfran_ref_domain_lower, 'wordpress' ) ) { 364 $sfran_ref_icon = 'dashicons-wordpress'; 365 } 335 366 ?> 336 367 <tr> 337 <td><s trong><?php echo esc_html($sfran_ref['domain']); ?></strong></td>368 <td><span class="dashicons <?php echo esc_attr( $sfran_ref_icon ); ?>" style="font-size: 16px; width: 16px; height: 16px; margin-right: 6px; vertical-align: middle; color: #2271b1;"></span><strong><?php echo esc_html($sfran_ref['domain']); ?></strong></td> 338 369 <td><?php echo esc_html(number_format($sfran_ref['views'])); ?></td> 339 370 <td><?php echo esc_html(number_format($sfran_ref['visitors'])); ?></td> 340 <td><?php echo esc_html( $sfran_ref['percentage']); ?>%</td>371 <td><?php echo esc_html(number_format($sfran_ref['percentage'], 1)); ?>%</td> 341 372 <td> 342 373 <div style="background: #e8f0fe; border-radius: 3px; height: 20px; width: 100%;"> … … 386 417 foreach ($sfran_geographic_data as $sfran_geo): 387 418 $sfran_percentage = $sfran_total_geo_views > 0 ? round(($sfran_geo->views / $sfran_total_geo_views) * 100, 1) : 0; 388 $sfran_country_name = $sfran_geo->country_code === 'Unknown' ? __('Unknown', 'sfr-analytics') : $sfran_geo->country_code; 419 $sfran_flag = sfran_country_flag_emoji( $sfran_geo->country_code ); 420 $sfran_country_label = $sfran_geo->country_code === 'Unknown' 421 ? __('Unknown', 'sfr-analytics') 422 : sfran_country_name( $sfran_geo->country_code ); 389 423 ?> 390 424 <tr> 391 <td><strong><?php echo esc_html($sfran_country_name); ?></strong></td>425 <td><strong><?php if ( $sfran_flag ) { echo '<span style="font-size: 1.2em; margin-right: 6px;">' . esc_html( $sfran_flag ) . '</span>'; } echo esc_html( $sfran_country_label ); ?></strong></td> 392 426 <td><?php echo number_format($sfran_geo->views); ?></td> 393 427 <td><?php echo number_format($sfran_geo->visitors); ?></td> -
sfr-analytics/trunk/admin/views/settings.php
r3463796 r3463996 136 136 } 137 137 138 // Handle referrer exclusion actions 139 if (isset($_POST['sfran_add_referrer']) && check_admin_referer('sfran_add_referrer')) { 140 if (current_user_can('manage_options')) { 141 $sfran_new_referrer = isset($_POST['sfran_new_referrer_domain']) ? strtolower(sanitize_text_field(wp_unslash($_POST['sfran_new_referrer_domain']))) : ''; 142 // Strip protocol and trailing slashes 143 $sfran_new_referrer = preg_replace('#^https?://#', '', $sfran_new_referrer); 144 $sfran_new_referrer = preg_replace('#^www\.#', '', $sfran_new_referrer); 145 $sfran_new_referrer = rtrim($sfran_new_referrer, '/'); 146 147 if (!empty($sfran_new_referrer) && preg_match('/^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?(\.[a-z]{2,})+$/i', $sfran_new_referrer)) { 148 $sfran_exclude_referrers_list = get_option('sfran_exclude_referrers', []); 149 if (!in_array($sfran_new_referrer, $sfran_exclude_referrers_list, true)) { 150 $sfran_exclude_referrers_list[] = $sfran_new_referrer; 151 update_option('sfran_exclude_referrers', array_values(array_unique($sfran_exclude_referrers_list))); 152 $sfran_cleaned = function_exists('sfran_cleanup_referrer_spam') ? sfran_cleanup_referrer_spam(array($sfran_new_referrer)) : 0; 153 echo '<div class="notice notice-success"><p>'; 154 echo esc_html(sprintf( 155 /* translators: 1: domain name, 2: number of records cleaned */ 156 __('%1$s added to referrer blocklist. %2$d existing record(s) cleaned from the database.', 'sfr-analytics'), 157 $sfran_new_referrer, 158 $sfran_cleaned 159 )); 160 echo '</p></div>'; 161 } else { 162 echo '<div class="notice notice-warning"><p>' . esc_html__('That domain is already in the blocklist.', 'sfr-analytics') . '</p></div>'; 163 } 164 } else { 165 echo '<div class="notice notice-error"><p>' . esc_html__('Please enter a valid domain name (e.g. ghost-rider.site, semalt.com).', 'sfr-analytics') . '</p></div>'; 166 } 167 } 168 } 169 170 if (isset($_POST['sfran_remove_referrer']) && check_admin_referer('sfran_remove_referrer')) { 171 if (current_user_can('manage_options')) { 172 $sfran_remove_referrer_domain = isset($_POST['sfran_remove_referrer_domain']) ? sanitize_text_field(wp_unslash($_POST['sfran_remove_referrer_domain'])) : ''; 173 $sfran_exclude_referrers_list = get_option('sfran_exclude_referrers', []); 174 $sfran_exclude_referrers_list = array_values(array_diff($sfran_exclude_referrers_list, [$sfran_remove_referrer_domain])); 175 update_option('sfran_exclude_referrers', $sfran_exclude_referrers_list); 176 echo '<div class="notice notice-success"><p>' . esc_html(sprintf( 177 /* translators: %s is the domain name */ 178 __('%s removed from referrer blocklist. Note: existing data was already cleaned when the domain was added.', 'sfr-analytics'), 179 $sfran_remove_referrer_domain 180 )) . '</p></div>'; 181 } 182 } 183 138 184 // Re-read after actions 139 185 $sfran_exclude_ips = get_option('sfran_exclude_ips', []); 140 186 $sfran_exclude_countries = get_option('sfran_exclude_countries', []); 187 $sfran_exclude_referrers = get_option('sfran_exclude_referrers', []); 188 $sfran_builtin_spam_domains = function_exists('sfran_get_builtin_spam_referrers') ? sfran_get_builtin_spam_referrers() : []; 141 189 ?> 142 190 … … 443 491 444 492 <div class="sfran-content-section" style="margin-top: 30px;"> 493 <h2><?php esc_html_e('Referrer Spam Filtering', 'sfr-analytics'); ?></h2> 494 495 <div style="background: #f0f6fc; border: 1px solid #c3d4e5; border-radius: 4px; padding: 15px; margin-bottom: 20px;"> 496 <p style="margin-top: 0;"><strong><?php esc_html_e('What is referrer spam?', 'sfr-analytics'); ?></strong></p> 497 <p><?php esc_html_e('Referrer spam (also called "ghost referrals") are fake visits from bots that never actually load your site. They send fake referrer headers to pollute your analytics data, hoping you\'ll visit their URLs out of curiosity. Common examples include ghost-rider, semalt, and buttons-for-website.', 'sfr-analytics'); ?></p> 498 499 <p style="margin-bottom: 0;"><strong><?php esc_html_e('How to identify spam referrers:', 'sfr-analytics'); ?></strong></p> 500 <ul style="list-style: disc; margin-left: 20px; margin-top: 5px;"> 501 <li><?php esc_html_e('Go to your Dashboard and check the "Top Referring Sites" section.', 'sfr-analytics'); ?></li> 502 <li><?php esc_html_e('Look for domains you don\'t recognise and that have never genuinely linked to your site.', 'sfr-analytics'); ?></li> 503 <li><?php esc_html_e('Spam referrers often have odd or promotional-sounding domain names.', 'sfr-analytics'); ?></li> 504 </ul> 505 506 <p style="margin-bottom: 0;"><strong><?php esc_html_e('What happens when you block a domain:', 'sfr-analytics'); ?></strong></p> 507 <ul style="list-style: disc; margin-left: 20px; margin-top: 5px;"> 508 <li><?php esc_html_e('All existing records with that referrer are immediately cleaned from your database.', 'sfr-analytics'); ?></li> 509 <li><?php esc_html_e('Future visits from that domain will still be counted as pageviews, but the referrer will not appear in your reports.', 'sfr-analytics'); ?></li> 510 <li><?php esc_html_e('Your pageview, visitor, and session counts are preserved — only the referrer field is cleared.', 'sfr-analytics'); ?></li> 511 </ul> 512 </div> 513 514 <h3><?php esc_html_e('Built-in Spam Domains', 'sfr-analytics'); ?></h3> 515 <p class="description"><?php esc_html_e('These known spam domains are automatically blocked. No action needed — this list is maintained with each plugin update.', 'sfr-analytics'); ?></p> 516 <div style="background: #f1f1f1; padding: 10px 15px; border-radius: 4px; margin: 10px 0 20px; font-family: monospace; font-size: 12px; max-height: 120px; overflow-y: auto;"> 517 <?php echo esc_html(implode(', ', $sfran_builtin_spam_domains)); ?> 518 </div> 519 520 <h3><?php esc_html_e('Add Custom Blocked Domain', 'sfr-analytics'); ?></h3> 521 <form method="post" action="" style="display: flex; gap: 10px; align-items: flex-start; flex-wrap: wrap;"> 522 <?php wp_nonce_field('sfran_add_referrer'); ?> 523 <input type="hidden" name="sfran_add_referrer" value="1"> 524 <div> 525 <input type="text" name="sfran_new_referrer_domain" placeholder="<?php esc_attr_e('e.g. spam-domain.com', 'sfr-analytics'); ?>" class="regular-text" style="width: 300px;" /> 526 <p class="description"><?php esc_html_e('Enter the domain name only (no https:// or paths). Copy it from your Top Referring Sites list.', 'sfr-analytics'); ?></p> 527 </div> 528 <button type="submit" class="button button-primary"><?php esc_html_e('Block Domain', 'sfr-analytics'); ?></button> 529 </form> 530 531 <h3 style="margin-top: 20px;"><?php esc_html_e('Custom Blocked Domains', 'sfr-analytics'); ?></h3> 532 <?php if (!empty($sfran_exclude_referrers)): ?> 533 <table class="wp-list-table widefat fixed striped" style="max-width: 500px;"> 534 <thead> 535 <tr> 536 <th style="width: 70%;"><?php esc_html_e('Domain', 'sfr-analytics'); ?></th> 537 <th style="width: 30%;"><?php esc_html_e('Actions', 'sfr-analytics'); ?></th> 538 </tr> 539 </thead> 540 <tbody> 541 <?php foreach ($sfran_exclude_referrers as $sfran_blocked_domain): ?> 542 <tr> 543 <td><code><?php echo esc_html($sfran_blocked_domain); ?></code></td> 544 <td> 545 <form method="post" action="" style="display: inline;" onsubmit="return confirm('<?php esc_attr_e('Remove this domain from the blocklist? Note: this will not restore previously cleaned referrer data.', 'sfr-analytics'); ?>');"> 546 <?php wp_nonce_field('sfran_remove_referrer'); ?> 547 <input type="hidden" name="sfran_remove_referrer" value="1"> 548 <input type="hidden" name="sfran_remove_referrer_domain" value="<?php echo esc_attr($sfran_blocked_domain); ?>"> 549 <button type="submit" class="button button-small" style="color: #d63638;"><?php esc_html_e('Remove', 'sfr-analytics'); ?></button> 550 </form> 551 </td> 552 </tr> 553 <?php endforeach; ?> 554 </tbody> 555 </table> 556 <?php else: ?> 557 <p><em><?php esc_html_e('No custom blocked domains. The built-in list above is still active.', 'sfr-analytics'); ?></em></p> 558 <?php endif; ?> 559 </div> 560 561 <div class="sfran-content-section" style="margin-top: 30px;"> 445 562 <h2><?php esc_html_e('Data Management', 'sfr-analytics'); ?></h2> 446 563 -
sfr-analytics/trunk/assets/js/admin.js
r3463796 r3463996 197 197 case 'today': 198 198 start = formatDate(today); 199 break; 200 case 'last24h': 201 var d1 = new Date(today); 202 d1.setDate(d1.getDate() - 1); 203 start = formatDate(d1); 199 204 break; 200 205 case 'last7': -
sfr-analytics/trunk/includes/class-sfran-rest-api.php
r3463796 r3463996 110 110 'permission_callback' => [__CLASS__, 'check_permission'], 111 111 'args' => self::get_date_limit_args() 112 ]); 113 114 // Bulk endpoint — returns all data types in a single request (used by Hub) 115 register_rest_route('sfran/v1', '/bulk', [ 116 'methods' => 'GET', 117 'callback' => [__CLASS__, 'get_bulk'], 118 'permission_callback' => [__CLASS__, 'check_permission'], 119 'args' => self::get_date_limit_args() 120 ]); 121 122 // Daily endpoint — returns per-day snapshots for Hub permanent storage 123 register_rest_route('sfran/v1', '/daily', [ 124 'methods' => 'GET', 125 'callback' => [__CLASS__, 'get_daily'], 126 'permission_callback' => [__CLASS__, 'check_permission'], 127 'args' => self::get_date_range_args() 112 128 ]); 113 129 } … … 681 697 ]); 682 698 } 699 700 /** 701 * Bulk endpoint — returns all analytics data types in a single request. 702 * Used by SFR Analytics Hub to avoid 13 sequential HTTP calls. 703 */ 704 public static function get_bulk($request) { 705 $start_date = $request->get_param('start_date'); 706 $end_date = $request->get_param('end_date'); 707 $limit = $request->get_param('limit') ?: 10; 708 709 // Validate date range 710 if (strtotime($end_date) < strtotime($start_date)) { 711 return new WP_Error('invalid_date_range', __('End date must be after start date.', 'sfr-analytics'), ['status' => 400]); 712 } 713 $days_diff = (strtotime($end_date) - strtotime($start_date)) / 86400; 714 if ($days_diff > 365) { 715 return new WP_Error('date_range_too_large', __('Date range cannot exceed 365 days.', 'sfr-analytics'), ['status' => 400]); 716 } 717 718 $result = []; 719 720 // 1. Summary 721 $result['summary'] = sfran_calculate_summary_stats($start_date, $end_date, false); 722 723 // 2. Top content 724 $top_content_raw = sfran_get_top_content($start_date, $end_date, $limit, 0, ''); 725 $top_content = []; 726 foreach ($top_content_raw as $post) { 727 $top_content[] = [ 728 'post_id' => intval($post->post_id), 729 'title' => $post->post_title ?: __('(No Title)', 'sfr-analytics'), 730 'views' => intval($post->views), 731 'visitors' => intval($post->visitors), 732 'url' => get_permalink($post->post_id), 733 ]; 734 } 735 $result['top_content'] = ['posts' => $top_content, 'total' => count($top_content)]; 736 737 // 3. Traffic sources (both limit=10 for table and limit=15 for chart) 738 $sources_10 = sfran_get_traffic_sources($start_date, $end_date, $limit); 739 $sources_15 = sfran_get_traffic_sources($start_date, $end_date, 15); 740 $fmt_sources = function ($sources) { 741 $total = array_sum(wp_list_pluck($sources, 'visits')); 742 $out = []; 743 foreach ($sources as $s) { 744 $out[] = [ 745 'referrer' => $s->referrer, 746 'visits' => intval($s->visits), 747 'visitors' => intval($s->visitors), 748 'percentage' => $total > 0 ? round(($s->visits / $total) * 100, 2) : 0, 749 ]; 750 } 751 return ['sources' => $out, 'total_visits' => $total]; 752 }; 753 $result['traffic_sources'] = $fmt_sources($sources_10); 754 $result['traffic_sources_15'] = $fmt_sources($sources_15); 755 756 // 4. Time series 757 $ts_rows = function_exists('sfran_get_time_series') ? sfran_get_time_series($start_date, $end_date) : []; 758 $ts_formatted = []; 759 foreach ($ts_rows as $row) { 760 $ts_formatted[] = [ 761 'date' => $row->date, 762 'views' => intval($row->views), 763 'visitors' => intval($row->visitors), 764 'sessions' => intval($row->sessions), 765 ]; 766 } 767 $result['time_series'] = ['series' => $ts_formatted]; 768 769 // 5. Campaigns 770 $result['campaigns'] = ['campaigns' => function_exists('sfran_get_campaign_stats') ? sfran_get_campaign_stats($start_date, $end_date) : []]; 771 772 // 6. Entry pages 773 $entry_raw = function_exists('sfran_get_entry_pages') ? sfran_get_entry_pages($start_date, $end_date, $limit) : []; 774 $entry_fmt = []; 775 foreach ($entry_raw as $page) { 776 $post = get_post($page->post_id); 777 $entry_fmt[] = [ 778 'post_id' => intval($page->post_id), 779 'title' => $post ? $post->post_title : __('(Deleted Post)', 'sfr-analytics'), 780 'url' => get_permalink($page->post_id), 781 'entries' => intval($page->entries), 782 'visitors' => intval($page->unique_visitors), 783 ]; 784 } 785 $result['entry_pages'] = ['pages' => $entry_fmt]; 786 787 // 7. Exit pages 788 $exit_raw = function_exists('sfran_get_exit_pages') ? sfran_get_exit_pages($start_date, $end_date, $limit) : []; 789 $exit_fmt = []; 790 foreach ($exit_raw as $page) { 791 $post = get_post($page->post_id); 792 $exit_fmt[] = [ 793 'post_id' => intval($page->post_id), 794 'title' => $post ? $post->post_title : __('(Deleted Post)', 'sfr-analytics'), 795 'url' => get_permalink($page->post_id), 796 'exits' => intval($page->exits), 797 'visitors' => intval($page->unique_visitors), 798 ]; 799 } 800 $result['exit_pages'] = ['pages' => $exit_fmt]; 801 802 // 8. Referrers 803 $result['referrers'] = ['referrers' => function_exists('sfran_get_top_referrers') ? sfran_get_top_referrers($start_date, $end_date, 20) : []]; 804 805 // 9. Devices 806 $devices_raw = function_exists('sfran_get_device_breakdown') ? sfran_get_device_breakdown($start_date, $end_date) : []; 807 $devices_fmt = []; 808 foreach ($devices_raw as $d) { 809 $devices_fmt[] = [ 810 'device_type' => $d->device_type, 811 'views' => intval($d->views), 812 'visitors' => intval($d->visitors), 813 ]; 814 } 815 $result['devices'] = ['devices' => $devices_fmt]; 816 817 // 10. Browsers & OS 818 $browsers_raw = function_exists('sfran_get_browser_breakdown') ? sfran_get_browser_breakdown($start_date, $end_date, $limit) : []; 819 $os_raw = function_exists('sfran_get_os_breakdown') ? sfran_get_os_breakdown($start_date, $end_date, $limit) : []; 820 $browsers_fmt = []; 821 foreach ($browsers_raw as $b) { 822 $browsers_fmt[] = ['browser' => $b->browser, 'views' => intval($b->views), 'visitors' => intval($b->visitors)]; 823 } 824 $os_fmt = []; 825 foreach ($os_raw as $o) { 826 $os_fmt[] = ['os' => $o->os, 'views' => intval($o->views), 'visitors' => intval($o->visitors)]; 827 } 828 $result['browsers'] = ['browsers' => $browsers_fmt, 'operating_systems' => $os_fmt]; 829 830 // 11. Geographic 831 $geo_raw = function_exists('sfran_get_geographic_breakdown') ? sfran_get_geographic_breakdown($start_date, $end_date, 15) : []; 832 $geo_fmt = []; 833 foreach ($geo_raw as $c) { 834 $geo_fmt[] = ['country_code' => $c->country_code, 'views' => intval($c->views), 'visitors' => intval($c->visitors)]; 835 } 836 $result['geographic'] = ['countries' => $geo_fmt]; 837 838 return rest_ensure_response([ 839 'success' => true, 840 'data' => $result, 841 'timestamp' => current_time('c'), 842 ]); 843 } 844 845 /** 846 * Daily endpoint — returns per-day snapshots for Hub permanent storage. 847 * 848 * Runs ~10 GROUP BY DATE queries (efficient) and returns all data types per day. 849 * Used by the Hub sync engine to fetch and store daily data incrementally. 850 * 851 * @param WP_REST_Request $request Request object 852 * @return WP_REST_Response|WP_Error Response 853 */ 854 public static function get_daily( $request ) { 855 $start_date = $request->get_param( 'start_date' ); 856 $end_date = $request->get_param( 'end_date' ); 857 858 // Validate date range 859 if ( strtotime( $end_date ) < strtotime( $start_date ) ) { 860 return new WP_Error( 'invalid_date_range', __( 'End date must be after start date.', 'sfr-analytics' ), [ 'status' => 400 ] ); 861 } 862 $days_diff = ( strtotime( $end_date ) - strtotime( $start_date ) ) / 86400; 863 if ( $days_diff > 365 ) { 864 return new WP_Error( 'date_range_too_large', __( 'Date range cannot exceed 365 days.', 'sfr-analytics' ), [ 'status' => 400 ] ); 865 } 866 867 $data = sfran_get_daily_breakdown( $start_date, $end_date ); 868 869 return rest_ensure_response( [ 870 'success' => true, 871 'days' => $data, 872 'start_date' => $start_date, 873 'end_date' => $end_date, 874 'timestamp' => current_time( 'c' ), 875 ] ); 876 } 683 877 } -
sfr-analytics/trunk/includes/class-sfran-tracker.php
r3463796 r3463996 101 101 'os' => $device_info['os'], 102 102 'user_agent_hash' => hash('sha256', $user_agent), 103 'ip_address' => self::get_tracking_ip(), 103 104 'is_bot' => $is_bot ? 1 : 0, 104 105 'is_entry' => $is_entry_page ? 1 : 0, … … 146 147 $browser_placeholder = ($data['browser'] === null || $data['browser'] === '') ? 'NULL' : '%s'; 147 148 $os_placeholder = ($data['os'] === null || $data['os'] === '') ? 'NULL' : '%s'; 148 $placeholders[] = "(%d, %s, %s, %s, %s, %s, %s, %s, %s, {$country_placeholder}, %s, {$browser_placeholder}, {$os_placeholder}, %s, %d, %d, %d, %d)"; 149 $ip_placeholder = (isset($data['ip_address']) && $data['ip_address'] !== null && $data['ip_address'] !== '') ? '%s' : 'NULL'; 150 $placeholders[] = "(%d, %s, %s, %s, %s, %s, %s, %s, %s, {$country_placeholder}, %s, {$browser_placeholder}, {$os_placeholder}, %s, {$ip_placeholder}, %d, %d, %d, %d)"; 149 151 150 152 $values[] = $data['post_id']; … … 171 173 } 172 174 $values[] = $data['user_agent_hash']; 175 // Only add ip_address to values if it's not null 176 if (isset($data['ip_address']) && $data['ip_address'] !== null && $data['ip_address'] !== '') { 177 $values[] = $data['ip_address']; 178 } 173 179 $values[] = $data['is_bot']; 174 180 $values[] = $data['is_entry']; … … 179 185 // Build query - table name is safe (from $wpdb->prefix) 180 186 // Column names are hardcoded, so safe 181 $column_list = 'post_id, post_type, viewed_at, visitor_hash, session_hash, referrer, utm_source, utm_medium, utm_campaign, country_code, device_type, browser, os, user_agent_hash, i s_bot, is_entry, is_exit, is_verified';187 $column_list = 'post_id, post_type, viewed_at, visitor_hash, session_hash, referrer, utm_source, utm_medium, utm_campaign, country_code, device_type, browser, os, user_agent_hash, ip_address, is_bot, is_entry, is_exit, is_verified'; 182 188 183 189 $query = "INSERT INTO {$table} ({$column_list}) VALUES " . implode(', ', $placeholders); … … 476 482 return null; 477 483 } 484 485 // Don't track excluded referrer domains (spam filtering) 486 $excluded_referrers = get_option('sfran_exclude_referrers', array()); 487 $builtin_referrers = sfran_get_builtin_spam_referrers(); 488 $all_excluded = array_merge($builtin_referrers, $excluded_referrers); 489 490 $ref_domain = isset($ref_url_parsed['host']) ? strtolower($ref_url_parsed['host']) : ''; 491 // Remove www. prefix for matching 492 $ref_domain = preg_replace('/^www\./', '', $ref_domain); 493 494 foreach ($all_excluded as $blocked) { 495 $blocked = strtolower(trim($blocked)); 496 if (empty($blocked)) { 497 continue; 498 } 499 if ($ref_domain === $blocked || substr($ref_domain, -(strlen($blocked) + 1)) === '.' . $blocked) { 500 return null; 501 } 502 } 478 503 479 504 return $referrer; … … 493 518 } 494 519 520 /** 521 * Get IP address for storage — respects the anonymize-IP setting. 522 * 523 * @return string|null IP address (anonymised if setting enabled), or null on failure. 524 */ 525 private static function get_tracking_ip() { 526 $ip = sfran_get_client_ip(); 527 if (empty($ip)) { 528 return null; 529 } 530 if (get_option('sfran_anonymize_ip', false)) { 531 // Zero the last octet for IPv4, last 80 bits for IPv6 532 if (strpos($ip, ':') !== false) { 533 // IPv6 — mask to /48 534 $packed = inet_pton($ip); 535 if ($packed !== false) { 536 $ip = inet_ntop(substr($packed, 0, 6) . str_repeat("\0", 10)); 537 } 538 } else { 539 // IPv4 — mask to /24 (e.g. 1.2.3.4 → 1.2.3.0) 540 $ip = preg_replace('/\.\d+$/', '.0', $ip); 541 } 542 } 543 return sanitize_text_field($ip); 544 } 545 495 546 /** 496 547 * Get country code from available sources … … 700 751 os VARCHAR(50), 701 752 user_agent_hash VARCHAR(64), 753 ip_address VARCHAR(45) DEFAULT NULL, 702 754 is_bot TINYINT(1) DEFAULT 0, 703 755 is_entry TINYINT(1) DEFAULT 0, … … 776 828 * Schema version: bump when adding new columns/indexes so upgrade runs again. 777 829 */ 778 const SCHEMA_VERSION = 2;830 const SCHEMA_VERSION = 3; 779 831 780 832 /** … … 804 856 'is_exit' => "ALTER TABLE {$table} ADD COLUMN is_exit TINYINT(1) DEFAULT 0 AFTER is_entry", 805 857 'is_verified' => "ALTER TABLE {$table} ADD COLUMN is_verified TINYINT(1) DEFAULT 0 AFTER is_exit", 858 'ip_address' => "ALTER TABLE {$table} ADD COLUMN ip_address VARCHAR(45) DEFAULT NULL AFTER user_agent_hash", 806 859 ]; 807 860 … … 825 878 'idx_device_type' => "ALTER TABLE {$table} ADD INDEX idx_device_type (device_type)", 826 879 'idx_verified' => "ALTER TABLE {$table} ADD INDEX idx_verified (is_verified, viewed_at)", 880 'idx_ip_address' => "ALTER TABLE {$table} ADD INDEX idx_ip_address (ip_address)", 827 881 ]; 828 882 -
sfr-analytics/trunk/includes/functions.php
r3463796 r3463996 188 188 'visitors' => intval($stats->visitors ?? 0), 189 189 'sessions' => intval($stats->sessions ?? 0), 190 'human_percentage' => round(floatval($stats->human_percentage ?? 0), 1), 190 191 'verified_percentage' => 100 191 192 ]; … … 950 951 // Get the site's own domain to exclude self-referrals 951 952 $site_host = wp_parse_url(home_url(), PHP_URL_HOST); 953 $site_host_normalised = preg_replace('/^www\./', '', $site_host); 952 954 953 955 // Get all non-empty referrers in the date range … … 973 975 foreach ($referrers as $row) { 974 976 $host = wp_parse_url($row->referrer, PHP_URL_HOST); 975 if (empty($host) || $host === $site_host) {977 if (empty($host)) { 976 978 continue; 977 979 } 978 980 // Remove www. prefix for grouping 979 981 $host = preg_replace('/^www\./', '', $host); 982 983 // Skip self-referrals (compare after www. stripping) 984 if ($host === $site_host_normalised) { 985 continue; 986 } 980 987 981 988 if (!isset($domains[$host])) { … … 1011 1018 return $results; 1012 1019 } 1020 1021 /** 1022 * Get built-in list of known referral spam domains. 1023 * 1024 * These are ghost referrers and spam bots that send fake referrer headers. 1025 * They never actually visit your site but pollute analytics data. 1026 * 1027 * @return array List of spam domain strings. 1028 */ 1029 function sfran_get_builtin_spam_referrers() { 1030 return array( 1031 'ghost-rider.site', 1032 'semalt.com', 1033 'buttons-for-website.com', 1034 'buttons-for-your-website.com', 1035 'darodar.com', 1036 'ilovevitaly.com', 1037 'priceg.com', 1038 'hulfingtonpost.com', 1039 'best-seo-offer.com', 1040 'best-seo-solution.com', 1041 'get-free-traffic-now.com', 1042 'buy-cheap-online.info', 1043 'free-social-buttons.com', 1044 'free-share-buttons.com', 1045 'event-tracking.com', 1046 'trafficmonetize.org', 1047 'traffic2money.com', 1048 'success-seo.com', 1049 'webmonetizer.net', 1050 'seo-platform.com', 1051 'ranksonic.info', 1052 'rankings-analytics.com', 1053 'floating-share-buttons.com', 1054 'o-o-8-o-o.com', 1055 '4webmasters.org', 1056 'cenoval.ru', 1057 'savetubevideo.com', 1058 'descargar-musica-gratis.net', 1059 'kambasoft.com', 1060 'screentoolkit.com', 1061 ); 1062 } 1063 1064 /** 1065 * Clean referrer spam entries from the database. 1066 * 1067 * Sets the referrer field to NULL for any pageview records where the 1068 * referrer domain matches one of the provided spam domains. This preserves 1069 * the pageview data itself (views, visitors, sessions still count). 1070 * 1071 * @param array $domains List of domain strings to clean. 1072 * @return int Number of records cleaned. 1073 */ 1074 function sfran_cleanup_referrer_spam( $domains ) { 1075 if ( empty( $domains ) ) { 1076 return 0; 1077 } 1078 1079 global $wpdb; 1080 $table = $wpdb->prefix . 'sfran_analytics'; 1081 1082 $cleaned = 0; 1083 foreach ( $domains as $domain ) { 1084 $domain = strtolower( trim( $domain ) ); 1085 if ( empty( $domain ) ) { 1086 continue; 1087 } 1088 // Match referrer URLs containing this domain (e.g. https://ghost-rider.site/anything or https://sub.ghost-rider.site/...) 1089 $like_pattern = '%://' . $wpdb->esc_like( $domain ) . '%'; 1090 $like_pattern_sub = '%://%.'. $wpdb->esc_like( $domain ) . '%'; 1091 1092 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with prepared LIKE patterns 1093 $result = $wpdb->query( $wpdb->prepare( 1094 "UPDATE {$table} SET referrer = NULL WHERE referrer IS NOT NULL AND (referrer LIKE %s OR referrer LIKE %s)", 1095 $like_pattern, 1096 $like_pattern_sub 1097 ) ); 1098 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1099 1100 if ( $result !== false ) { 1101 $cleaned += $result; 1102 } 1103 } 1104 1105 return $cleaned; 1106 } 1107 1108 /** 1109 * Get daily breakdown of all analytics data for a date range. 1110 * 1111 * Returns per-day snapshots suitable for permanent storage by the Hub. 1112 * Runs a small number of GROUP BY DATE queries (not per-day loops) for efficiency. 1113 * 1114 * @param string $start_date Start date (Y-m-d) 1115 * @param string $end_date End date (Y-m-d) 1116 * @return array Keyed by date (Y-m-d), each containing all data types 1117 */ 1118 function sfran_get_daily_breakdown( $start_date, $end_date ) { 1119 global $wpdb; 1120 1121 $start_date = sanitize_text_field( $start_date ); 1122 $end_date = sanitize_text_field( $end_date ); 1123 $table = $wpdb->prefix . 'sfran_analytics'; 1124 $start_dt = $start_date . ' 00:00:00'; 1125 $end_dt = $end_date . ' 23:59:59'; 1126 1127 $days = []; 1128 $all_post_ids = []; 1129 1130 // 1. Summary per day 1131 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table 1132 $summary_rows = $wpdb->get_results( $wpdb->prepare( 1133 "SELECT 1134 DATE(viewed_at) AS date, 1135 COUNT(*) AS views, 1136 COUNT(DISTINCT visitor_hash) AS visitors, 1137 COUNT(DISTINCT session_hash) AS sessions, 1138 COUNT(CASE WHEN is_bot = 0 THEN 1 END) * 100.0 / GREATEST(COUNT(*), 1) AS human_percentage 1139 FROM {$table} 1140 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1141 GROUP BY DATE(viewed_at) 1142 ORDER BY date ASC", 1143 $start_dt, 1144 $end_dt 1145 ) ); 1146 // phpcs:enable 1147 foreach ( $summary_rows as $row ) { 1148 $days[ $row->date ]['summary'] = [ 1149 'views' => intval( $row->views ), 1150 'visitors' => intval( $row->visitors ), 1151 'sessions' => intval( $row->sessions ), 1152 'human_percentage' => round( floatval( $row->human_percentage ), 1 ), 1153 ]; 1154 } 1155 1156 // 2. Top content per day 1157 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1158 $content_rows = $wpdb->get_results( $wpdb->prepare( 1159 "SELECT DATE(viewed_at) AS date, post_id, COUNT(*) AS views, COUNT(DISTINCT visitor_hash) AS visitors 1160 FROM {$table} 1161 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1162 GROUP BY DATE(viewed_at), post_id 1163 ORDER BY date ASC, views DESC", 1164 $start_dt, 1165 $end_dt 1166 ) ); 1167 // phpcs:enable 1168 $content_by_date = []; 1169 foreach ( $content_rows as $row ) { 1170 $all_post_ids[] = intval( $row->post_id ); 1171 $content_by_date[ $row->date ][] = [ 1172 'post_id' => intval( $row->post_id ), 1173 'views' => intval( $row->views ), 1174 'visitors' => intval( $row->visitors ), 1175 ]; 1176 } 1177 foreach ( $content_by_date as $date => $items ) { 1178 $days[ $date ]['top_content'] = array_slice( $items, 0, 20 ); 1179 } 1180 1181 // 3. Traffic sources per day 1182 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1183 $sources_rows = $wpdb->get_results( $wpdb->prepare( 1184 "SELECT DATE(viewed_at) AS date, COALESCE(NULLIF(referrer, ''), 'Direct') AS referrer, COUNT(*) AS visits, COUNT(DISTINCT visitor_hash) AS visitors 1185 FROM {$table} 1186 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1187 GROUP BY DATE(viewed_at), referrer 1188 ORDER BY date ASC, visits DESC", 1189 $start_dt, 1190 $end_dt 1191 ) ); 1192 // phpcs:enable 1193 $sources_by_date = []; 1194 foreach ( $sources_rows as $row ) { 1195 $sources_by_date[ $row->date ][] = [ 1196 'referrer' => $row->referrer, 1197 'visits' => intval( $row->visits ), 1198 'visitors' => intval( $row->visitors ), 1199 ]; 1200 } 1201 foreach ( $sources_by_date as $date => $items ) { 1202 $days[ $date ]['traffic_sources'] = array_slice( $items, 0, 50 ); 1203 } 1204 1205 // 4. Entry pages per day (column may not exist on older versions) 1206 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1207 $entry_col_exists = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'is_entry' ) ); 1208 // phpcs:enable 1209 if ( ! empty( $entry_col_exists ) ) { 1210 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1211 $entry_rows = $wpdb->get_results( $wpdb->prepare( 1212 "SELECT DATE(viewed_at) AS date, post_id, COUNT(*) AS entries, COUNT(DISTINCT visitor_hash) AS visitors 1213 FROM {$table} 1214 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 AND is_entry = 1 1215 GROUP BY DATE(viewed_at), post_id 1216 ORDER BY date ASC, entries DESC", 1217 $start_dt, 1218 $end_dt 1219 ) ); 1220 // phpcs:enable 1221 $entry_by_date = []; 1222 foreach ( $entry_rows as $row ) { 1223 $all_post_ids[] = intval( $row->post_id ); 1224 $entry_by_date[ $row->date ][] = [ 1225 'post_id' => intval( $row->post_id ), 1226 'entries' => intval( $row->entries ), 1227 'visitors' => intval( $row->visitors ), 1228 ]; 1229 } 1230 foreach ( $entry_by_date as $date => $items ) { 1231 $days[ $date ]['entry_pages'] = array_slice( $items, 0, 20 ); 1232 } 1233 } 1234 1235 // 5. Exit pages per day 1236 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1237 $exit_rows = $wpdb->get_results( $wpdb->prepare( 1238 "SELECT DATE(viewed_at) AS date, post_id, COUNT(*) AS exits, COUNT(DISTINCT visitor_hash) AS visitors 1239 FROM {$table} 1240 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 AND is_exit = 1 1241 GROUP BY DATE(viewed_at), post_id 1242 ORDER BY date ASC, exits DESC", 1243 $start_dt, 1244 $end_dt 1245 ) ); 1246 // phpcs:enable 1247 $exit_by_date = []; 1248 foreach ( $exit_rows as $row ) { 1249 $all_post_ids[] = intval( $row->post_id ); 1250 $exit_by_date[ $row->date ][] = [ 1251 'post_id' => intval( $row->post_id ), 1252 'exits' => intval( $row->exits ), 1253 'visitors' => intval( $row->visitors ), 1254 ]; 1255 } 1256 foreach ( $exit_by_date as $date => $items ) { 1257 $days[ $date ]['exit_pages'] = array_slice( $items, 0, 20 ); 1258 } 1259 1260 // 6. Devices per day 1261 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1262 $device_rows = $wpdb->get_results( $wpdb->prepare( 1263 "SELECT DATE(viewed_at) AS date, device_type, COUNT(*) AS views, COUNT(DISTINCT visitor_hash) AS visitors 1264 FROM {$table} 1265 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1266 GROUP BY DATE(viewed_at), device_type 1267 ORDER BY date ASC, views DESC", 1268 $start_dt, 1269 $end_dt 1270 ) ); 1271 // phpcs:enable 1272 foreach ( $device_rows as $row ) { 1273 $days[ $row->date ]['devices'][] = [ 1274 'device_type' => $row->device_type, 1275 'views' => intval( $row->views ), 1276 'visitors' => intval( $row->visitors ), 1277 ]; 1278 } 1279 1280 // 7. Browsers per day 1281 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1282 $browser_rows = $wpdb->get_results( $wpdb->prepare( 1283 "SELECT DATE(viewed_at) AS date, COALESCE(NULLIF(browser, ''), 'Unknown') AS browser, 1284 COUNT(*) AS views, COUNT(DISTINCT visitor_hash) AS visitors 1285 FROM {$table} 1286 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1287 GROUP BY DATE(viewed_at), COALESCE(NULLIF(browser, ''), 'Unknown') 1288 ORDER BY date ASC, views DESC", 1289 $start_dt, 1290 $end_dt 1291 ) ); 1292 // phpcs:enable 1293 $browsers_by_date = []; 1294 foreach ( $browser_rows as $row ) { 1295 $browsers_by_date[ $row->date ][] = [ 1296 'browser' => $row->browser, 1297 'views' => intval( $row->views ), 1298 'visitors' => intval( $row->visitors ), 1299 ]; 1300 } 1301 foreach ( $browsers_by_date as $date => $items ) { 1302 $days[ $date ]['browsers'] = array_slice( $items, 0, 20 ); 1303 } 1304 1305 // 8. OS per day 1306 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1307 $os_rows = $wpdb->get_results( $wpdb->prepare( 1308 "SELECT DATE(viewed_at) AS date, COALESCE(NULLIF(os, ''), 'Unknown') AS os, 1309 COUNT(*) AS views, COUNT(DISTINCT visitor_hash) AS visitors 1310 FROM {$table} 1311 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1312 GROUP BY DATE(viewed_at), COALESCE(NULLIF(os, ''), 'Unknown') 1313 ORDER BY date ASC, views DESC", 1314 $start_dt, 1315 $end_dt 1316 ) ); 1317 // phpcs:enable 1318 $os_by_date = []; 1319 foreach ( $os_rows as $row ) { 1320 $os_by_date[ $row->date ][] = [ 1321 'os' => $row->os, 1322 'views' => intval( $row->views ), 1323 'visitors' => intval( $row->visitors ), 1324 ]; 1325 } 1326 foreach ( $os_by_date as $date => $items ) { 1327 $days[ $date ]['os'] = array_slice( $items, 0, 20 ); 1328 } 1329 1330 // 9. Geographic per day 1331 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1332 $geo_rows = $wpdb->get_results( $wpdb->prepare( 1333 "SELECT DATE(viewed_at) AS date, COALESCE(NULLIF(country_code, ''), 'Unknown') AS country_code, 1334 COUNT(*) AS views, COUNT(DISTINCT visitor_hash) AS visitors 1335 FROM {$table} 1336 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1337 GROUP BY DATE(viewed_at), COALESCE(NULLIF(country_code, ''), 'Unknown') 1338 ORDER BY date ASC, views DESC", 1339 $start_dt, 1340 $end_dt 1341 ) ); 1342 // phpcs:enable 1343 $geo_by_date = []; 1344 foreach ( $geo_rows as $row ) { 1345 $geo_by_date[ $row->date ][] = [ 1346 'country_code' => $row->country_code, 1347 'views' => intval( $row->views ), 1348 'visitors' => intval( $row->visitors ), 1349 ]; 1350 } 1351 foreach ( $geo_by_date as $date => $items ) { 1352 $days[ $date ]['geographic'] = array_slice( $items, 0, 30 ); 1353 } 1354 1355 // 10. Campaigns per day 1356 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter 1357 $campaign_rows = $wpdb->get_results( $wpdb->prepare( 1358 "SELECT DATE(viewed_at) AS date, utm_campaign, 1359 COUNT(*) AS views, COUNT(DISTINCT visitor_hash) AS visitors, COUNT(DISTINCT session_hash) AS sessions 1360 FROM {$table} 1361 WHERE viewed_at BETWEEN %s AND %s AND is_verified = 1 1362 AND utm_campaign IS NOT NULL AND utm_campaign != '' 1363 GROUP BY DATE(viewed_at), utm_campaign 1364 ORDER BY date ASC, views DESC", 1365 $start_dt, 1366 $end_dt 1367 ) ); 1368 // phpcs:enable 1369 $campaign_by_date = []; 1370 foreach ( $campaign_rows as $row ) { 1371 $campaign_by_date[ $row->date ][] = [ 1372 'campaign' => $row->utm_campaign, 1373 'views' => intval( $row->views ), 1374 'visitors' => intval( $row->visitors ), 1375 'sessions' => intval( $row->sessions ), 1376 ]; 1377 } 1378 foreach ( $campaign_by_date as $date => $items ) { 1379 $days[ $date ]['campaigns'] = array_slice( $items, 0, 20 ); 1380 } 1381 1382 // Batch lookup post titles and URLs 1383 $all_post_ids = array_unique( $all_post_ids ); 1384 $post_map = []; 1385 foreach ( $all_post_ids as $pid ) { 1386 $post = get_post( $pid ); 1387 $post_map[ $pid ] = [ 1388 'title' => $post ? $post->post_title : __( '(Deleted Post)', 'sfr-analytics' ), 1389 'url' => get_permalink( $pid ), 1390 ]; 1391 } 1392 1393 // Compute referrers (domain-level) per day from traffic_sources 1394 $site_host = wp_parse_url( home_url(), PHP_URL_HOST ); 1395 if ( $site_host && strpos( $site_host, 'www.' ) === 0 ) { 1396 $site_host = substr( $site_host, 4 ); 1397 } 1398 $custom_domains = get_option( 'sfran_custom_spam_referrers', [] ); 1399 $builtin_domains = function_exists( 'sfran_get_builtin_spam_referrers' ) ? sfran_get_builtin_spam_referrers() : []; 1400 $excluded = array_merge( $builtin_domains, is_array( $custom_domains ) ? $custom_domains : [] ); 1401 1402 // Enrich and finalise each day 1403 foreach ( $days as $date => &$day_data ) { 1404 // Enrich top_content with titles/urls 1405 if ( ! empty( $day_data['top_content'] ) ) { 1406 foreach ( $day_data['top_content'] as &$item ) { 1407 $item['title'] = $post_map[ $item['post_id'] ]['title'] ?? __( '(Unknown)', 'sfr-analytics' ); 1408 $item['url'] = $post_map[ $item['post_id'] ]['url'] ?? ''; 1409 } 1410 unset( $item ); 1411 } 1412 if ( ! empty( $day_data['entry_pages'] ) ) { 1413 foreach ( $day_data['entry_pages'] as &$item ) { 1414 $item['title'] = $post_map[ $item['post_id'] ]['title'] ?? __( '(Unknown)', 'sfr-analytics' ); 1415 $item['url'] = $post_map[ $item['post_id'] ]['url'] ?? ''; 1416 } 1417 unset( $item ); 1418 } 1419 if ( ! empty( $day_data['exit_pages'] ) ) { 1420 foreach ( $day_data['exit_pages'] as &$item ) { 1421 $item['title'] = $post_map[ $item['post_id'] ]['title'] ?? __( '(Unknown)', 'sfr-analytics' ); 1422 $item['url'] = $post_map[ $item['post_id'] ]['url'] ?? ''; 1423 } 1424 unset( $item ); 1425 } 1426 1427 // Build referrers (domain-level) from traffic_sources 1428 $domain_map = []; 1429 $total_referrer_views = 0; 1430 foreach ( $day_data['traffic_sources'] ?? [] as $src ) { 1431 $parsed = wp_parse_url( $src['referrer'] ); 1432 $host = isset( $parsed['host'] ) ? strtolower( $parsed['host'] ) : ''; 1433 if ( strpos( $host, 'www.' ) === 0 ) { 1434 $host = substr( $host, 4 ); 1435 } 1436 if ( empty( $host ) || $host === $site_host ) { 1437 continue; 1438 } 1439 // Skip spam domains 1440 $is_spam = false; 1441 foreach ( $excluded as $spam ) { 1442 if ( $host === $spam || substr( $host, -( strlen( $spam ) + 1 ) ) === '.' . $spam ) { 1443 $is_spam = true; 1444 break; 1445 } 1446 } 1447 if ( $is_spam ) { 1448 continue; 1449 } 1450 if ( ! isset( $domain_map[ $host ] ) ) { 1451 $domain_map[ $host ] = [ 'domain' => $host, 'views' => 0, 'visitors' => 0 ]; 1452 } 1453 $domain_map[ $host ]['views'] += $src['visits']; 1454 $domain_map[ $host ]['visitors'] += $src['visitors']; 1455 $total_referrer_views += $src['visits']; 1456 } 1457 foreach ( $domain_map as &$ref ) { 1458 $ref['percentage'] = $total_referrer_views > 0 ? round( ( $ref['views'] / $total_referrer_views ) * 100, 1 ) : 0; 1459 } 1460 unset( $ref ); 1461 usort( $domain_map, function ( $a, $b ) { 1462 return $b['views'] - $a['views']; 1463 } ); 1464 $day_data['referrers'] = array_slice( array_values( $domain_map ), 0, 30 ); 1465 1466 // Ensure defaults for all data types 1467 $day_data = array_merge( [ 1468 'summary' => [ 'views' => 0, 'visitors' => 0, 'sessions' => 0, 'human_percentage' => 0 ], 1469 'top_content' => [], 1470 'traffic_sources' => [], 1471 'referrers' => [], 1472 'entry_pages' => [], 1473 'exit_pages' => [], 1474 'devices' => [], 1475 'browsers' => [], 1476 'os' => [], 1477 'geographic' => [], 1478 'campaigns' => [], 1479 ], $day_data ); 1480 } 1481 unset( $day_data ); 1482 1483 return $days; 1484 } 1485 1486 /** 1487 * Convert a 2-letter ISO country code to a flag emoji. 1488 * 1489 * Each letter is offset to the Regional Indicator Symbol range (U+1F1E6). 1490 * 1491 * @param string $code 2-letter ISO 3166-1 alpha-2 code (e.g. 'GB', 'US') 1492 * @return string Flag emoji, or empty string for unknown/invalid codes 1493 */ 1494 function sfran_country_flag_emoji( $code ) { 1495 $code = strtoupper( trim( $code ) ); 1496 if ( strlen( $code ) !== 2 || 'UNKNOWN' === strtoupper( $code ) ) { 1497 return ''; 1498 } 1499 $first = mb_chr( 0x1F1E6 + ord( $code[0] ) - ord( 'A' ) ); 1500 $second = mb_chr( 0x1F1E6 + ord( $code[1] ) - ord( 'A' ) ); 1501 return $first . $second; 1502 } 1503 1504 /** 1505 * Get a human-readable country name from a 2-letter ISO code. 1506 * 1507 * @param string $code 2-letter ISO 3166-1 alpha-2 code 1508 * @return string Country name, or the code itself if not found 1509 */ 1510 function sfran_country_name( $code ) { 1511 static $names = null; 1512 if ( null === $names ) { 1513 $names = [ 1514 'AD' => 'Andorra', 'AE' => 'United Arab Emirates', 'AF' => 'Afghanistan', 'AG' => 'Antigua and Barbuda', 'AL' => 'Albania', 1515 'AM' => 'Armenia', 'AO' => 'Angola', 'AR' => 'Argentina', 'AT' => 'Austria', 'AU' => 'Australia', 1516 'AZ' => 'Azerbaijan', 'BA' => 'Bosnia and Herzegovina', 'BB' => 'Barbados', 'BD' => 'Bangladesh', 'BE' => 'Belgium', 1517 'BF' => 'Burkina Faso', 'BG' => 'Bulgaria', 'BH' => 'Bahrain', 'BI' => 'Burundi', 'BJ' => 'Benin', 1518 'BN' => 'Brunei', 'BO' => 'Bolivia', 'BR' => 'Brazil', 'BS' => 'Bahamas', 'BT' => 'Bhutan', 1519 'BW' => 'Botswana', 'BY' => 'Belarus', 'BZ' => 'Belize', 'CA' => 'Canada', 'CD' => 'DR Congo', 1520 'CF' => 'Central African Republic', 'CG' => 'Congo', 'CH' => 'Switzerland', 'CI' => 'Ivory Coast', 'CL' => 'Chile', 1521 'CM' => 'Cameroon', 'CN' => 'China', 'CO' => 'Colombia', 'CR' => 'Costa Rica', 'CU' => 'Cuba', 1522 'CY' => 'Cyprus', 'CZ' => 'Czech Republic', 'DE' => 'Germany', 'DJ' => 'Djibouti', 'DK' => 'Denmark', 1523 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'DZ' => 'Algeria', 'EC' => 'Ecuador', 'EE' => 'Estonia', 1524 'EG' => 'Egypt', 'ES' => 'Spain', 'ET' => 'Ethiopia', 'FI' => 'Finland', 'FJ' => 'Fiji', 1525 'FR' => 'France', 'GA' => 'Gabon', 'GB' => 'United Kingdom', 'GE' => 'Georgia', 'GH' => 'Ghana', 1526 'GR' => 'Greece', 'GT' => 'Guatemala', 'GN' => 'Guinea', 'GY' => 'Guyana', 'HK' => 'Hong Kong', 1527 'HN' => 'Honduras', 'HR' => 'Croatia', 'HT' => 'Haiti', 'HU' => 'Hungary', 'ID' => 'Indonesia', 1528 'IE' => 'Ireland', 'IL' => 'Israel', 'IN' => 'India', 'IQ' => 'Iraq', 'IR' => 'Iran', 1529 'IS' => 'Iceland', 'IT' => 'Italy', 'JM' => 'Jamaica', 'JO' => 'Jordan', 'JP' => 'Japan', 1530 'KE' => 'Kenya', 'KG' => 'Kyrgyzstan', 'KH' => 'Cambodia', 'KR' => 'South Korea', 'KW' => 'Kuwait', 1531 'KZ' => 'Kazakhstan', 'LA' => 'Laos', 'LB' => 'Lebanon', 'LK' => 'Sri Lanka', 'LR' => 'Liberia', 1532 'LT' => 'Lithuania', 'LU' => 'Luxembourg', 'LV' => 'Latvia', 'LY' => 'Libya', 'MA' => 'Morocco', 1533 'MD' => 'Moldova', 'ME' => 'Montenegro', 'MG' => 'Madagascar', 'MK' => 'North Macedonia', 'ML' => 'Mali', 1534 'MM' => 'Myanmar', 'MN' => 'Mongolia', 'MX' => 'Mexico', 'MY' => 'Malaysia', 'MZ' => 'Mozambique', 1535 'NA' => 'Namibia', 'NE' => 'Niger', 'NG' => 'Nigeria', 'NI' => 'Nicaragua', 'NL' => 'Netherlands', 1536 'NO' => 'Norway', 'NP' => 'Nepal', 'NZ' => 'New Zealand', 'OM' => 'Oman', 'PA' => 'Panama', 1537 'PE' => 'Peru', 'PG' => 'Papua New Guinea', 'PH' => 'Philippines', 'PK' => 'Pakistan', 'PL' => 'Poland', 1538 'PR' => 'Puerto Rico', 'PS' => 'Palestine', 'PT' => 'Portugal', 'PY' => 'Paraguay', 'QA' => 'Qatar', 1539 'RO' => 'Romania', 'RS' => 'Serbia', 'RU' => 'Russia', 'RW' => 'Rwanda', 'SA' => 'Saudi Arabia', 1540 'SD' => 'Sudan', 'SE' => 'Sweden', 'SG' => 'Singapore', 'SI' => 'Slovenia', 'SK' => 'Slovakia', 1541 'SL' => 'Sierra Leone', 'SN' => 'Senegal', 'SO' => 'Somalia', 'SS' => 'South Sudan', 'SV' => 'El Salvador', 1542 'SY' => 'Syria', 'TG' => 'Togo', 'TH' => 'Thailand', 'TJ' => 'Tajikistan', 'TM' => 'Turkmenistan', 1543 'TN' => 'Tunisia', 'TR' => 'Turkey', 'TT' => 'Trinidad and Tobago', 'TW' => 'Taiwan', 'TZ' => 'Tanzania', 1544 'UA' => 'Ukraine', 'UG' => 'Uganda', 'US' => 'United States', 'UY' => 'Uruguay', 'UZ' => 'Uzbekistan', 1545 'VE' => 'Venezuela', 'VN' => 'Vietnam', 'YE' => 'Yemen', 'ZA' => 'South Africa', 'ZM' => 'Zambia', 1546 'ZW' => 'Zimbabwe', 1547 ]; 1548 } 1549 $code = strtoupper( trim( $code ) ); 1550 return isset( $names[ $code ] ) ? $names[ $code ] : $code; 1551 } 1552 1553 /** 1554 * Get a dashicon class for a traffic source type. 1555 * 1556 * @param string $type Traffic source type (Direct, Search Engines, Social Media, Referral Sites, Email, Campaign) 1557 * @return string Dashicon CSS class 1558 */ 1559 function sfran_traffic_source_icon( $type ) { 1560 $map = [ 1561 'Direct' => 'dashicons-admin-home', 1562 'Search Engines' => 'dashicons-search', 1563 'Social Media' => 'dashicons-share', 1564 'Referral Sites' => 'dashicons-admin-links', 1565 'Email' => 'dashicons-email-alt', 1566 'Campaign' => 'dashicons-megaphone', 1567 ]; 1568 return isset( $map[ $type ] ) ? $map[ $type ] : 'dashicons-admin-site-alt3'; 1569 } -
sfr-analytics/trunk/readme.txt
r3463796 r3463996 4 4 Requires at least: 6.0 5 5 Tested up to: 6.9 6 Stable tag: 0. 5.06 Stable tag: 0.6.0 7 7 Requires PHP: 7.4 8 8 License: GPLv2 or later … … 165 165 == Changelog == 166 166 167 = 0.6.0 = 168 * Added referral spam filtering — built-in blocklist of 30+ known ghost referrer domains, auto-cleanup on upgrade 169 * Added manual referrer domain blocking via Settings with one-click add/remove 170 * Added IP address tracking with anonymisation support (respects existing anonymize-IP setting) 171 * Added IP address column to database schema with automatic migration 172 * Added self-referral filtering — own domain no longer appears in Top Referring Sites 173 * Added "Last 24 Hours" date preset button on the dashboard 174 * Added country flag emoji and full country names in the Geographic Distribution table 175 * Added icons for traffic source types (Direct, Search Engines, Social Media, Referral Sites) 176 * Added recognisable icons for popular referring domains (Google, Facebook, Twitter/X, YouTube, LinkedIn, Reddit, Pinterest, Bing, etc.) 177 * Added REST API `/bulk` endpoint — returns all data types in a single request 178 * Added REST API `/daily` endpoint — returns per-day snapshots for external integrations 179 * Added human traffic percentage to summary stats API response 180 * Improved referral percentage display to 1 decimal place 181 * Improved Top Referring Sites description text 182 167 183 = 0.5.0 = 168 184 * Improved page picker filtering for UTM Link Builder — excludes password-protected pages, functional pages, and non-content post types -
sfr-analytics/trunk/sfr-analytics.php
r3463796 r3463996 4 4 * Plugin URI: https://supportfromrichard.co.uk/sfr-analytics/ 5 5 * Description: Lightweight WordPress analytics tracking plugin that stores data locally on your site. 6 * Version: 0. 5.06 * Version: 0.6.0 7 7 * Author: Support From Richard 8 8 * Author URI: https://supportfromrichard.co.uk … … 22 22 // Define plugin constants 23 23 if (!defined('SFRAN_VERSION')) { 24 define('SFRAN_VERSION', '0. 5.0');24 define('SFRAN_VERSION', '0.6.0'); 25 25 } 26 26 … … 68 68 SFRAN_Admin::init(); 69 69 SFRAN_Assets::init(); 70 71 // Run spam cleanup on upgrade (plugin updates don't trigger activation hook) 72 sfran_maybe_run_spam_cleanup(); 70 73 } 71 74 add_action('plugins_loaded', 'sfran_init'); 75 76 /** 77 * Run built-in spam referrer cleanup if not yet done for the current version. 78 * 79 * Tracked via the sfran_spam_cleanup_version option so it only runs once 80 * per plugin version that updates the built-in list. 81 */ 82 function sfran_maybe_run_spam_cleanup() { 83 $cleanup_version = get_option('sfran_spam_cleanup_version', ''); 84 if ($cleanup_version !== SFRAN_VERSION) { 85 $builtin = sfran_get_builtin_spam_referrers(); 86 sfran_cleanup_referrer_spam($builtin); 87 update_option('sfran_spam_cleanup_version', SFRAN_VERSION); 88 } 89 } 72 90 73 91 /** … … 93 111 add_option('sfran_exclude_ips', []); 94 112 add_option('sfran_exclude_countries', []); 113 add_option('sfran_exclude_referrers', []); 95 114 add_option('sfran_session_timeout', 1800); // 30 minutes 96 115 add_option('sfran_anonymize_ip', false); 97 116 add_option('sfran_respect_dnt', true); 117 118 // Run built-in spam referrer cleanup on activation 119 sfran_maybe_run_spam_cleanup(); 98 120 99 121 // Schedule buffer processing
Note: See TracChangeset
for help on using the changeset viewer.