wp_date( 'Y-m-d' ), 'channel' => Attribution::channel( $touch ), 'source' => mb_substr( Attribution::source( $touch ), 0, 191 ), 'medium' => mb_substr( Attribution::medium( $touch ), 0, 191 ), 'campaign' => mb_substr( (string) ( $touch['utm_campaign'] ?? '' ), 0, 191 ), 'landing_page' => mb_substr( (string) strtok( (string) ( $touch['lp'] ?? '' ), '?' ), 0, 191 ), 'device' => (string) ( $visitor['device'] ?? '' ), 'browser' => mb_substr( (string) ( $visitor['browser'] ?? '' ), 0, 40 ), 'country' => Geo::code( $visitor['country'] ?? '' ), 'city' => mb_substr( (string) ( $visitor['city'] ?? '' ), 0, 100 ), ); // 0, 0 stands for an unknown place: no city lies there. $point = Geo::point( $visitor['lat'] ?? null, $visitor['lon'] ?? null ); $lat = (float) $point[0]; $lon = (float) $point[1]; $row['hash'] = md5( implode( '|', $row ) . '|' . $lat . ',' . $lon ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (day, hash, channel, source, medium, campaign, landing_page, device, browser, country, city, lat, lon, visits) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %f, %f, 1) ON DUPLICATE KEY UPDATE visits = visits + 1', Installer::visits_table(), $row['day'], $row['hash'], $row['channel'], $row['source'], $row['medium'], $row['campaign'], $row['landing_page'], $row['device'], $row['browser'], $row['country'], $row['city'], $lat, $lon ) ); return false !== $result; } /** * Visits per group. * * @param string $from UTC start, empty for all time. * @param string $to UTC end, empty for now. * @param string $column Report column. * @param bool|string $by_channel True to keep the bought clicks of a source apart, "campaign" to split visits without a campaign by channel. * @return array|null Group => visits, or null when visits cannot be grouped by this column. */ public static function counts( $from, $to, $column, $by_channel = false ) { global $wpdb; $map = array( 'channel' => 'channel', 'last_channel' => 'channel', 'source' => 'source', 'last_source' => 'source', 'campaign' => 'campaign', 'last_campaign' => 'campaign', 'landing_page' => 'landing_page', 'device' => 'device', 'browser' => 'browser', 'country' => 'country', 'city' => 'city', ); if ( ! isset( $map[ $column ] ) ) { return null; } $group = $map[ $column ]; $channel = $by_channel ? 'channel' : $group; list( $start, $end ) = Reports::sql_days( $from, $to ); $rows = self::summed( 'counts|' . $group . '|' . $channel, $start, $end, static function ( $first, $last ) use ( $wpdb, $group, $channel ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $wpdb->get_results( $wpdb->prepare( 'SELECT %i AS grp, %i AS chn, SUM(visits) AS total FROM %i WHERE day >= %s AND day < %s GROUP BY %i, %i', $group, $channel, Installer::visits_table(), $first, $last, $group, $channel ) ); } ); $counts = array(); foreach ( (array) $rows as $row ) { $key = Reports::split_key( $by_channel, (string) $row->grp, (string) $row->chn ); $counts[ $key ] = ( $counts[ $key ] ?? 0 ) + (int) $row->total; } return $counts; } /** * Rows summed from the visits of some days. The days before today no longer change, so their * sums are kept until the next day; only today is counted again. The rows of the two parts * come one after the other, for the caller to add up. * * @param string $name What is summed, part of the cache key. * @param string $start First day, Y-m-d. * @param string $end Day after the last, Y-m-d. * @param callable $query Runs the query for a first day and the day after the last. * @return object[] */ public static function summed( $name, $start, $end, callable $query ) { $today = wp_date( 'Y-m-d' ); $rows = array(); if ( $start < $today ) { $last = min( $end, $today ); $key = 'dealroot_visits_' . md5( $name . '|' . $start . '|' . $last . '|' . (int) get_option( self::GENERATION, 0 ) ); $past = get_transient( $key ); if ( ! is_array( $past ) ) { $past = array_map( 'get_object_vars', (array) $query( $start, $last ) ); set_transient( $key, $past, DAY_IN_SECONDS ); } $rows = array_map( static function ( $row ) { return (object) $row; }, $past ); } if ( $end > $today ) { $rows = array_merge( $rows, (array) $query( max( $start, $today ), $end ) ); } return $rows; } /** * Starts the sums of past days afresh, after visits were deleted. */ public static function forget_sums() { update_option( self::GENERATION, (int) get_option( self::GENERATION, 0 ) + 1 ); } /** * Visits counted today and the days before. * * @param int $days Days back, today included. * @return int */ public static function recent( $days ) { global $wpdb; $since = wp_date( 'Y-m-d', time() - ( max( 1, (int) $days ) - 1 ) * DAY_IN_SECONDS ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT SUM(visits) FROM %i WHERE day >= %s', Installer::visits_table(), $since ) ); } /** * Whether any visit has been counted yet. * * @return bool */ public static function has_data() { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i LIMIT 1', Installer::visits_table() ) ); } /** * Removes counters older than the retention window. * * @param int $days Age in days. * @return int Rows deleted. */ public static function purge_older_than( $days ) { global $wpdb; if ( $days < 1 ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $deleted = (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE day < %s', Installer::visits_table(), wp_date( 'Y-m-d', time() - (int) $days * DAY_IN_SECONDS ) ) ); if ( $deleted ) { self::forget_sums(); } return $deleted; } /** * Whether the user agent looks like a crawler or monitoring tool. * * @param string $user_agent User agent. * @return bool */ public static function is_bot( $user_agent ) { if ( '' === $user_agent ) { return true; } return (bool) preg_match( '/bot|crawl|spider|slurp|headless|lighthouse|pagespeed|gtmetrix|pingdom|uptime|monitor|preview|facebookexternalhit|curl|wget|python|java\\/|go-http|axios|node-fetch/i', $user_agent ); } }