5 * MINUTE_IN_SECONDS, 'display' => __( 'Every five minutes (UseClick.io)', 'useclick' ), ); return $schedules; } /** * Schedule the daily refresh of the bot list. */ public static function schedule_events() { if ( ! wp_next_scheduled( self::CRON_TOKENS ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_TOKENS ); } } /** * Remove the scheduled jobs. */ public static function unschedule_events() { wp_clear_scheduled_hook( self::CRON_TOKENS ); wp_clear_scheduled_hook( self::CRON_FLUSH ); } /** * Whether bot visits are reported (an explicit opt-in on the Website analytics screen). * * @return bool */ public static function bots_enabled() { return (bool) get_option( self::OPT_ENABLED, false ) && (bool) get_option( self::OPT_TRACK_BOTS, false ) && '' !== (string) get_option( self::OPT_SITE_ID, '' ); } /** * User-agent tokens of known bots (AI assistants, AI crawlers, search engines), from an * autoloaded option that WP-Cron refreshes daily. Never fetched during a visitor's request. * * @return string[] Lower-cased tokens. */ private static function bot_tokens() { $tokens = get_option( self::OPT_BOT_TOKENS ); if ( ! is_array( $tokens ) || empty( $tokens ) ) { $tokens = self::DEFAULT_BOT_TOKENS; } /** * Filters the user-agent tokens that mark a request as a bot visit worth reporting. * * @param string[] $tokens Lower-cased tokens. */ return (array) apply_filters( 'useclick_bot_tokens', $tokens ); } /** * WP-Cron: fetch the current bot list from UseClick.io (only while bot reporting is on). */ public static function refresh_bot_tokens() { if ( ! self::bots_enabled() ) { return; } $response = wp_remote_get( USECLICK_APP_URL . '/api/analytics/bot-signatures', array( 'timeout' => 5 ) ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return; } $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); if ( is_array( $decoded ) && ! empty( $decoded['tokens'] ) && is_array( $decoded['tokens'] ) ) { $tokens = array_values( array_filter( array_map( 'strtolower', array_map( 'strval', $decoded['tokens'] ) ) ) ); update_option( self::OPT_BOT_TOKENS, array_slice( $tokens, 0, 500 ), true ); } } /** * Queue a bot visit (GPTBot, ClaudeBot, ChatGPT-User, Googlebot, ...) for UseClick.io. * * Most AI crawlers read the HTML without running JavaScript, so the tracker never sees * them. Only when bot reporting is switched on, and only for bot user agents: the user * agent is checked before anything else, so visits by people cost nothing. Visits are * sent in batches by WP-Cron; no IP address is sent. */ public static function report_bot_visit() { if ( ! self::bots_enabled() || is_admin() || wp_doing_ajax() || wp_doing_cron() || is_preview() ) { return; } $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $user_agent ) { return; } $ua_lower = strtolower( $user_agent ); $is_bot = false; foreach ( self::bot_tokens() as $token ) { if ( '' !== $token && false !== strpos( $ua_lower, (string) $token ) ) { $is_bot = true; break; } } if ( ! $is_bot ) { return; } $queue = get_option( self::OPT_BOT_QUEUE, array() ); $queue = is_array( $queue ) ? $queue : array(); if ( count( $queue ) >= self::QUEUE_MAX ) { array_shift( $queue ); } $queue[] = array( 'site_id' => (string) get_option( self::OPT_SITE_ID, '' ), 'path' => isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/', 'user_agent' => substr( $user_agent, 0, 500 ), 'referrer' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', 'country' => isset( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ), 0, 2 ) : '', ); update_option( self::OPT_BOT_QUEUE, $queue, false ); if ( ! wp_next_scheduled( self::CRON_FLUSH ) ) { wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, self::CRON_FLUSH ); } } /** * WP-Cron: send queued bot visits to UseClick.io in batches of up to 25. */ public static function flush_bot_queue() { $queue = get_option( self::OPT_BOT_QUEUE, array() ); if ( ! is_array( $queue ) || empty( $queue ) ) { return; } $api_key = useclick_get_api_key(); if ( '' === $api_key ) { delete_option( self::OPT_BOT_QUEUE ); return; } $sent = 0; foreach ( array_chunk( $queue, self::BATCH_SIZE ) as $batch ) { $response = wp_remote_post( USECLICK_APP_URL . '/api/analytics/server-hit', array( 'timeout' => 10, 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $batch ), ) ); $status = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); if ( 429 === $status || 0 === $status || $status >= 500 ) { break; // Try the rest on the next run. } $sent += count( $batch ); } $rest = array_slice( $queue, $sent ); if ( empty( $rest ) ) { delete_option( self::OPT_BOT_QUEUE ); } else { update_option( self::OPT_BOT_QUEUE, $rest, false ); wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, self::CRON_FLUSH ); } } /** * Tracker script URL. * * @return string */ public static function tracker_url() { /** * Filters the tracker script URL. * * @param string $url Default https://useclick.io/analytics.js (or the USECLICK_TRACKER_URL constant). */ return (string) apply_filters( 'useclick_tracker_url', USECLICK_TRACKER_URL ); } /** * Whether the tracker should load for this request. * * @return bool */ private static function should_track() { if ( ! get_option( self::OPT_ENABLED, false ) || '' === (string) get_option( self::OPT_SITE_ID, '' ) ) { return false; } if ( is_preview() || is_customize_preview() ) { return false; } if ( is_user_logged_in() ) { if ( get_option( self::OPT_EXCLUDE_LOGGED, false ) ) { return false; } if ( get_option( self::OPT_EXCLUDE_ADMINS, true ) && current_user_can( 'manage_options' ) ) { return false; } } /** * Filters whether the UseClick.io tracker loads on the current page. * * @param bool $track True to load the tracker. */ return (bool) apply_filters( 'useclick_should_track', true ); } /** * Enqueue the tracker in the page head: * */ public static function enqueue_tracker() { if ( ! self::should_track() ) { return; } $src = add_query_arg( 's', rawurlencode( (string) get_option( self::OPT_SITE_ID, '' ) ), self::tracker_url() ); // No version query string: the tracker is a hosted file that UseClick.io keeps up to date. // In the head with defer, so it never blocks rendering and sees the first page view. wp_enqueue_script( self::HANDLE, $src, array(), null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- hosted tracker, always current. array( 'strategy' => 'defer', 'in_footer' => false, ) ); } /** * This site's host name from the Site Address setting, e.g. example.com. A staging copy can * report as the live site with the `useclick_site_host` filter. * * @return string */ public static function site_host() { $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); return strtolower( (string) apply_filters( 'useclick_site_host', $host ) ); } /** * Add this site to the UseClick.io account (or find it there) and save it as the tracked site. * * @return array|WP_Error The website { id, domain, name }. */ public static function register_this_site() { $host = self::site_host(); if ( '' === $host ) { return new WP_Error( 'no_host', __( 'Could not read this site\'s domain from Settings > General > Site Address.', 'useclick' ) ); } $api = new UseClick_API(); $result = $api->create_website( $host, wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ); if ( is_wp_error( $result ) && 'VALIDATION_ERROR' === $result->get_error_code() ) { return new WP_Error( 'not_public', /* translators: %s: this site's host name */ sprintf( __( '%s is not a public domain, so it cannot be added to UseClick.io. Switch website analytics on from the live site (for example example.com).', 'useclick' ), $host ) ); } if ( is_wp_error( $result ) ) { return $result; } $site = isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : array(); if ( empty( $site['id'] ) ) { return new WP_Error( 'invalid_response', __( 'UseClick.io returned an unexpected response. Please try again.', 'useclick' ) ); } update_option( self::OPT_SITE_ID, sanitize_text_field( (string) $site['id'] ) ); update_option( self::OPT_SITE_DOMAIN, sanitize_text_field( isset( $site['domain'] ) ? (string) $site['domain'] : $host ) ); return $site; } /** * Back to the analytics screen with a status, and an error message for one page view. * * @param string $status Status key. * @param WP_Error|null $error Error to show. */ private static function redirect_back( $status, $error = null ) { if ( $error instanceof WP_Error ) { set_transient( 'useclick_analytics_error_' . get_current_user_id(), $error->get_error_message(), 5 * MINUTE_IN_SECONDS ); } UseClick_Admin::set_flash( 'useclick-analytics', $status ); wp_safe_redirect( admin_url( 'admin.php?page=useclick-analytics' ) ); exit; } /** * Option holding why tracking could not start automatically (shown on the analytics screen). */ const OPT_START_ERROR = 'useclick_analytics_start_error'; /** * Start tracking this site: add it to the UseClick.io account (or find it there) and switch * the tracker on. Runs right after the account is connected, so there is nothing to set up. * When the plan does not allow it (trial used, website limit) or the site has no public * domain, the reason is kept for the analytics screen. * * @return bool True when tracking is on. */ public static function auto_start() { if ( get_option( self::OPT_ENABLED, false ) && '' !== (string) get_option( self::OPT_SITE_ID, '' ) ) { delete_option( self::OPT_START_ERROR ); return true; } $site = self::register_this_site(); if ( is_wp_error( $site ) ) { update_option( self::OPT_START_ERROR, $site->get_error_message(), false ); return false; } update_option( self::OPT_ENABLED, 1 ); delete_option( self::OPT_START_ERROR ); return true; } /** * "Start tracking" button: shown only when automatic start did not work (retry). */ public static function handle_start() { if ( ! current_user_can( UseClick_Admin::capability() ) ) { wp_die( esc_html__( 'You are not allowed to change these settings.', 'useclick' ), 403 ); } check_admin_referer( 'useclick_start_analytics' ); if ( self::auto_start() ) { self::redirect_back( 'started' ); } self::redirect_back( 'error', new WP_Error( 'start_failed', (string) get_option( self::OPT_START_ERROR, '' ) ) ); } /** * Save the tracking settings (pause, who is not counted, bot reporting). */ public static function handle_save() { if ( ! current_user_can( UseClick_Admin::capability() ) ) { wp_die( esc_html__( 'You are not allowed to change these settings.', 'useclick' ), 403 ); } check_admin_referer( 'useclick_save_analytics' ); // 1/0, not true/false: update_option() does not create a missing option for false, so // unticking a default-on box on a fresh install would silently not save. update_option( self::OPT_EXCLUDE_LOGGED, empty( $_POST['useclick_exclude_logged_in'] ) ? 0 : 1 ); update_option( self::OPT_EXCLUDE_ADMINS, empty( $_POST['useclick_exclude_admins'] ) ? 0 : 1 ); update_option( self::OPT_TRACK_BOTS, empty( $_POST['useclick_track_bots'] ) ? 0 : 1 ); $paused = ! empty( $_POST['useclick_paused'] ); if ( $paused ) { update_option( self::OPT_ENABLED, 0 ); } elseif ( ! self::auto_start() ) { self::redirect_back( 'error', new WP_Error( 'start_failed', (string) get_option( self::OPT_START_ERROR, '' ) ) ); } delete_option( 'useclick_notice_bots_opt_in' ); if ( self::bots_enabled() ) { self::schedule_events(); self::refresh_bot_tokens(); } else { delete_option( self::OPT_BOT_QUEUE ); } self::redirect_back( 'saved' ); } /** * The report's date range: 1, 7, 30 or 90 days. A range link carries a nonce; the choice is * remembered per user. * * @return int */ private static function report_days() { $allowed = array( 1, 7, 30, 90 ); $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; if ( isset( $_GET['range'] ) && false !== wp_verify_nonce( $nonce, 'useclick_report_range' ) ) { $days = absint( $_GET['range'] ); if ( in_array( $days, $allowed, true ) ) { update_user_meta( get_current_user_id(), 'useclick_report_days', $days ); return $days; } } $saved = (int) get_user_meta( get_current_user_id(), 'useclick_report_days', true ); return in_array( $saved, $allowed, true ) ? $saved : 30; } /** * Render the Website analytics screen. */ public static function render_admin_page() { if ( ! current_user_can( UseClick_Admin::capability() ) ) { return; } $useclick_websites = array(); $useclick_plan = null; $useclick_trial = null; $useclick_error = ''; $useclick_stats = null; $useclick_stats_e = ''; $useclick_status = UseClick_Admin::take_flash( 'useclick-analytics' ); $useclick_days = self::report_days(); if ( UseClick_Admin::has_api_key() ) { $api = new UseClick_API(); $result = $api->get_websites(); if ( is_wp_error( $result ) ) { $useclick_error = $result->get_error_message(); } else { $useclick_websites = isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : array(); $useclick_plan = isset( $result['plan'] ) && is_array( $result['plan'] ) ? $result['plan'] : null; $useclick_trial = isset( $result['trial'] ) && is_array( $result['trial'] ) ? $result['trial'] : null; } $site_id = (string) get_option( self::OPT_SITE_ID, '' ); if ( '' === $useclick_error && '' !== $site_id ) { $stats = $api->get_website_stats( $site_id, $useclick_days ); $useclick_stats = is_wp_error( $stats ) ? null : $stats; $useclick_stats_e = is_wp_error( $stats ) ? $stats->get_error_message() : ''; } } include USECLICK_PLUGIN_DIR . 'templates/analytics.php'; } }