Plugin Directory

Changeset 3463996


Ignore:
Timestamp:
02/18/2026 07:07:10 AM (6 weeks ago)
Author:
supportfromrichard
Message:

Release v0.6.0 — Referral spam filtering, IP tracking, dashboard icons, and Hub API endpoints.

  • Built-in blocklist of 30+ ghost referrer domains with auto-cleanup
  • Manual referrer domain blocking in Settings
  • IP address tracking with anonymisation support
  • Self-referral filtering for Top Referring Sites
  • Country flag emoji and full country names
  • Dashicons for traffic sources and referring domains
  • Last 24 Hours date preset
  • REST API /bulk and /daily endpoints for Hub integration
  • Human traffic percentage in summary stats
Location:
sfr-analytics
Files:
29 added
8 edited

Legend:

Unmodified
Added
Removed
  • sfr-analytics/trunk/admin/views/dashboard.php

    r3463796 r3463996  
    2727        <div class="sfran-date-presets">
    2828            <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>
    2930            <button type="button" class="button sfran-preset-btn" data-preset="last7"><?php esc_html_e('Last 7 Days', 'sfr-analytics'); ?></button>
    3031            <button type="button" class="button sfran-preset-btn" data-preset="last30"><?php esc_html_e('Last 30 Days', 'sfr-analytics'); ?></button>
     
    208209                        foreach ($sfran_traffic_sources as $sfran_source):
    209210                            $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'] );
    210212                        ?>
    211213                            <tr>
    212                                 <td><strong><?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>
    213215                                <td><?php echo number_format($sfran_source['visits']); ?></td>
    214216                                <td><?php echo number_format($sfran_source['visitors']); ?></td>
     
    317319            echo $sfran_is_hidden ? 'display: none;' : '';
    318320        ?>">
    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>
    320322            <table class="wp-list-table widefat fixed striped">
    321323                <thead>
     
    333335                    foreach ($sfran_top_referrers as $sfran_ref):
    334336                        $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                        }
    335366                    ?>
    336367                        <tr>
    337                             <td><strong><?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>
    338369                            <td><?php echo esc_html(number_format($sfran_ref['views'])); ?></td>
    339370                            <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>
    341372                            <td>
    342373                                <div style="background: #e8f0fe; border-radius: 3px; height: 20px; width: 100%;">
     
    386417                    foreach ($sfran_geographic_data as $sfran_geo):
    387418                        $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 );
    389423                    ?>
    390424                        <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>
    392426                            <td><?php echo number_format($sfran_geo->views); ?></td>
    393427                            <td><?php echo number_format($sfran_geo->visitors); ?></td>
  • sfr-analytics/trunk/admin/views/settings.php

    r3463796 r3463996  
    136136}
    137137
     138// Handle referrer exclusion actions
     139if (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
     170if (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
    138184// Re-read after actions
    139185$sfran_exclude_ips = get_option('sfran_exclude_ips', []);
    140186$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() : [];
    141189?>
    142190
     
    443491   
    444492    <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;">
    445562        <h2><?php esc_html_e('Data Management', 'sfr-analytics'); ?></h2>
    446563       
  • sfr-analytics/trunk/assets/js/admin.js

    r3463796 r3463996  
    197197            case 'today':
    198198                start = formatDate(today);
     199                break;
     200            case 'last24h':
     201                var d1 = new Date(today);
     202                d1.setDate(d1.getDate() - 1);
     203                start = formatDate(d1);
    199204                break;
    200205            case 'last7':
  • sfr-analytics/trunk/includes/class-sfran-rest-api.php

    r3463796 r3463996  
    110110            'permission_callback' => [__CLASS__, 'check_permission'],
    111111            '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()
    112128        ]);
    113129    }
     
    681697        ]);
    682698    }
     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    }
    683877}
  • sfr-analytics/trunk/includes/class-sfran-tracker.php

    r3463796 r3463996  
    101101            'os' => $device_info['os'],
    102102            'user_agent_hash' => hash('sha256', $user_agent),
     103            'ip_address' => self::get_tracking_ip(),
    103104            'is_bot' => $is_bot ? 1 : 0,
    104105            'is_entry' => $is_entry_page ? 1 : 0,
     
    146147            $browser_placeholder = ($data['browser'] === null || $data['browser'] === '') ? 'NULL' : '%s';
    147148            $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)";
    149151           
    150152            $values[] = $data['post_id'];
     
    171173            }
    172174            $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            }
    173179            $values[] = $data['is_bot'];
    174180            $values[] = $data['is_entry'];
     
    179185        // Build query - table name is safe (from $wpdb->prefix)
    180186        // 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, is_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';
    182188       
    183189        $query = "INSERT INTO {$table} ({$column_list}) VALUES " . implode(', ', $placeholders);
     
    476482            return null;
    477483        }
     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        }
    478503       
    479504        return $referrer;
     
    493518    }
    494519   
     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
    495546    /**
    496547     * Get country code from available sources
     
    700751            os VARCHAR(50),
    701752            user_agent_hash VARCHAR(64),
     753            ip_address VARCHAR(45) DEFAULT NULL,
    702754            is_bot TINYINT(1) DEFAULT 0,
    703755            is_entry TINYINT(1) DEFAULT 0,
     
    776828     * Schema version: bump when adding new columns/indexes so upgrade runs again.
    777829     */
    778     const SCHEMA_VERSION = 2;
     830    const SCHEMA_VERSION = 3;
    779831
    780832    /**
     
    804856            'is_exit' => "ALTER TABLE {$table} ADD COLUMN is_exit TINYINT(1) DEFAULT 0 AFTER is_entry",
    805857            '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",
    806859        ];
    807860       
     
    825878            'idx_device_type' => "ALTER TABLE {$table} ADD INDEX idx_device_type (device_type)",
    826879            '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)",
    827881        ];
    828882       
  • sfr-analytics/trunk/includes/functions.php

    r3463796 r3463996  
    188188        'visitors' => intval($stats->visitors ?? 0),
    189189        'sessions' => intval($stats->sessions ?? 0),
     190        'human_percentage' => round(floatval($stats->human_percentage ?? 0), 1),
    190191        'verified_percentage' => 100
    191192    ];
     
    950951    // Get the site's own domain to exclude self-referrals
    951952    $site_host = wp_parse_url(home_url(), PHP_URL_HOST);
     953    $site_host_normalised = preg_replace('/^www\./', '', $site_host);
    952954   
    953955    // Get all non-empty referrers in the date range
     
    973975    foreach ($referrers as $row) {
    974976        $host = wp_parse_url($row->referrer, PHP_URL_HOST);
    975         if (empty($host) || $host === $site_host) {
     977        if (empty($host)) {
    976978            continue;
    977979        }
    978980        // Remove www. prefix for grouping
    979981        $host = preg_replace('/^www\./', '', $host);
     982       
     983        // Skip self-referrals (compare after www. stripping)
     984        if ($host === $site_host_normalised) {
     985            continue;
     986        }
    980987       
    981988        if (!isset($domains[$host])) {
     
    10111018    return $results;
    10121019}
     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 */
     1029function 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 */
     1074function 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 */
     1118function 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 */
     1494function 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 */
     1510function 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 */
     1559function 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  
    44Requires at least: 6.0
    55Tested up to: 6.9
    6 Stable tag: 0.5.0
     6Stable tag: 0.6.0
    77Requires PHP: 7.4
    88License: GPLv2 or later
     
    165165== Changelog ==
    166166
     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
    167183= 0.5.0 =
    168184* 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  
    44 * Plugin URI:        https://supportfromrichard.co.uk/sfr-analytics/
    55 * Description:       Lightweight WordPress analytics tracking plugin that stores data locally on your site.
    6  * Version:           0.5.0
     6 * Version:           0.6.0
    77 * Author:            Support From Richard
    88 * Author URI:        https://supportfromrichard.co.uk
     
    2222// Define plugin constants
    2323if (!defined('SFRAN_VERSION')) {
    24     define('SFRAN_VERSION', '0.5.0');
     24    define('SFRAN_VERSION', '0.6.0');
    2525}
    2626
     
    6868    SFRAN_Admin::init();
    6969    SFRAN_Assets::init();
     70
     71    // Run spam cleanup on upgrade (plugin updates don't trigger activation hook)
     72    sfran_maybe_run_spam_cleanup();
    7073}
    7174add_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 */
     82function 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}
    7290
    7391/**
     
    93111    add_option('sfran_exclude_ips', []);
    94112    add_option('sfran_exclude_countries', []);
     113    add_option('sfran_exclude_referrers', []);
    95114    add_option('sfran_session_timeout', 1800); // 30 minutes
    96115    add_option('sfran_anonymize_ip', false);
    97116    add_option('sfran_respect_dnt', true);
     117
     118    // Run built-in spam referrer cleanup on activation
     119    sfran_maybe_run_spam_cleanup();
    98120   
    99121    // Schedule buffer processing
Note: See TracChangeset for help on using the changeset viewer.