prefix . 'agentaisle_visits'; } /** * Hooks. */ public static function init() { add_action( 'template_redirect', array( __CLASS__, 'maybe_log' ), 1 ); add_action( 'do_robots', array( __CLASS__, 'maybe_log' ), 1 ); add_action( self::CRON_HOOK, array( __CLASS__, 'purge' ) ); if ( get_option( 'agentaisle_db_version' ) !== self::DB_VERSION ) { self::create_table(); } } /** * Activation: create table and schedule purge. */ public static function activate() { self::create_table(); if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK ); } } /** * Deactivation: clear cron. */ public static function deactivate() { wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Create or update the visits table. */ public static function create_table() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, bot varchar(64) NOT NULL, path varchar(255) NOT NULL, object_id bigint(20) unsigned NOT NULL DEFAULT 0, status smallint(5) unsigned NOT NULL DEFAULT 200, ip_hash char(16) NOT NULL DEFAULT '', visited_at datetime NOT NULL, PRIMARY KEY (id), KEY bot_time (bot, visited_at), KEY visited_at (visited_at) ) {$charset};"; dbDelta( $sql ); update_option( 'agentaisle_db_version', self::DB_VERSION, false ); } /** * Log the current request if it comes from a known AI bot. */ public static function maybe_log() { static $done = false; if ( $done ) { return; } $done = true; $settings = agentaisle_settings(); if ( empty( $settings['logging'] ) ) { return; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; $bot = AgentAisle_Bots::match( $ua ); if ( ! $bot ) { return; } $path = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = substr( (string) wp_parse_url( $path, PHP_URL_PATH ), 0, 255 ); $object_id = 0; if ( function_exists( 'is_singular' ) && is_singular() ) { $object_id = (int) get_queried_object_id(); } // Anonymised: a truncated salted hash, never the raw IP. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; $ip_hash = $ip ? substr( hash( 'sha256', $ip . wp_salt( 'auth' ) ), 0, 16 ) : ''; global $wpdb; $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table, write-only log. self::table(), array( 'bot' => $bot, 'path' => $path ? $path : '/', 'object_id' => $object_id, 'status' => is_404() ? 404 : 200, 'ip_hash' => $ip_hash, 'visited_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%d', '%d', '%s', '%s' ) ); /** * Fires after a known AI bot visit is logged. * * @param string $bot Bot token. * @param string $path Request path. * @param int $object_id Queried object ID or 0. */ do_action( 'agentaisle_bot_visit', $bot, $path, $object_id ); } /** * Delete logs older than the retention period. */ public static function purge() { global $wpdb; $days = max( 1, (int) agentaisle_settings()['retention_days'] ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE visited_at < %s', self::table(), self::since( $days ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, scheduled cleanup. } /** * Visits per bot over the last N days. * * @param int $days Days. * @return array */ public static function counts_by_bot( $days ) { global $wpdb; $key = 'bots_' . (int) $days; $rows = wp_cache_get( $key, 'agentaisle' ); if ( false === $rows ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT bot, COUNT(*) AS visits, MAX(visited_at) AS last_seen FROM %i WHERE visited_at >= %s GROUP BY bot ORDER BY visits DESC', self::table(), self::since( $days ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table, result cached. wp_cache_set( $key, $rows, 'agentaisle', 5 * MINUTE_IN_SECONDS ); } return $rows; } /** * Most-crawled paths. * * @param int $days Days. * @param int $limit Limit. * @return array */ public static function top_paths( $days, $limit = 10 ) { global $wpdb; $key = 'paths_' . (int) $days . '_' . (int) $limit; $rows = wp_cache_get( $key, 'agentaisle' ); if ( false === $rows ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT path, object_id, COUNT(*) AS visits FROM %i WHERE visited_at >= %s GROUP BY path, object_id ORDER BY visits DESC LIMIT %d', self::table(), self::since( $days ), (int) $limit ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table, result cached. wp_cache_set( $key, $rows, 'agentaisle', 5 * MINUTE_IN_SECONDS ); } return $rows; } /** * Daily totals. * * @param int $days Days. * @return array date => visits */ public static function daily( $days ) { global $wpdb; $key = 'daily_' . (int) $days; $rows = wp_cache_get( $key, 'agentaisle' ); if ( false === $rows ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT DATE(visited_at) AS d, COUNT(*) AS visits FROM %i WHERE visited_at >= %s GROUP BY d', self::table(), self::since( $days ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom table, result cached. wp_cache_set( $key, $rows, 'agentaisle', 5 * MINUTE_IN_SECONDS ); } $out = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $out[ gmdate( 'Y-m-d', time() - $i * DAY_IN_SECONDS ) ] = 0; } foreach ( (array) $rows as $row ) { if ( isset( $out[ $row->d ] ) ) { $out[ $row->d ] = (int) $row->visits; } } return $out; } /** * Cutoff datetime string. * * @param int $days Days. * @return string */ private static function since( $days ) { return gmdate( 'Y-m-d H:i:s', time() - (int) $days * DAY_IN_SECONDS ); } }