detector = $detector; } /** * Register hooks. Runs after the negotiator (which exits for served bots). */ public function hooks() { add_action( 'template_redirect', array( $this, 'maybe_track' ), 20 ); } /** * Map of AI-assistant referrer hosts → source key + human label. * The host is matched as an exact host or a dot-suffix. * * @return array host => array( key, label ). */ public static function source_map() { $map = array( 'chatgpt.com' => array( 'chatgpt', 'ChatGPT' ), 'chat.openai.com' => array( 'chatgpt', 'ChatGPT' ), 'openai.com' => array( 'chatgpt', 'ChatGPT' ), 'perplexity.ai' => array( 'perplexity', 'Perplexity' ), 'gemini.google.com' => array( 'gemini', 'Gemini' ), 'bard.google.com' => array( 'gemini', 'Gemini' ), 'copilot.microsoft.com'=> array( 'copilot', 'Copilot' ), 'claude.ai' => array( 'claude', 'Claude' ), 'you.com' => array( 'other', 'Other AI' ), 'poe.com' => array( 'other', 'Other AI' ), 'phind.com' => array( 'other', 'Other AI' ), ); /** * Filter the AI-referrer host map. * * @param array $map host => array( source_key, label ). */ return (array) apply_filters( 'adg_aim_referral_sources', $map ); } /** * Human labels keyed by source key (for rendering/CSV). * * @return array key => label. */ public static function source_labels() { $labels = array(); foreach ( self::source_map() as $entry ) { $labels[ $entry[0] ] = $entry[1]; } return $labels; } /** * Default aggregate structure. * * @return array */ public static function defaults() { return array( 'totals' => array(), // source_key => int. 'by_day' => array(), // 'Y-m-d' => int (all sources). 'by_page' => array(), // path => int. 'first_seen' => '', 'last_seen' => '', ); } /** * Get the aggregate (backfilling missing keys). * * @return array */ public static function get() { $data = get_option( ADG_AIM_OPT_REFERRALS, array() ); if ( ! is_array( $data ) ) { $data = array(); } return wp_parse_args( $data, self::defaults() ); } /** * Reset all referral data. */ public static function reset() { delete_option( ADG_AIM_OPT_REFERRALS ); } /** * Frontend entry point: record a referral if this is a human view from an AI host. */ public function maybe_track() { if ( ! (bool) ADG_AIM_Plugin::get_setting( 'referrals_enabled', true ) ) { return; } // Only real, public GET page views by anonymous visitors. if ( is_admin() || is_feed() || is_trackback() || is_robots() || is_404() ) { return; } if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { return; } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'GET' !== $method ) { return; } if ( is_user_logged_in() ) { return; // Do not count the site's own team navigating. } // Skip crawlers — this metric is about humans (UA match only, no IP cost). if ( false !== $this->detector->match_ua() ) { return; } $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; if ( '' === $referer ) { return; } $source = self::match_source( $referer ); if ( null === $source ) { return; } $this->record( $source, $this->current_path() ); } /** * Match a referrer URL against the AI-source map. * * @param string $referer Referrer URL. * @return string|null Source key or null. */ public static function match_source( $referer ) { $host = wp_parse_url( $referer, PHP_URL_HOST ); if ( ! is_string( $host ) || '' === $host ) { return null; } $host = strtolower( $host ); if ( 0 === strpos( $host, 'www.' ) ) { $host = substr( $host, 4 ); } // Never count same-site navigation. $self = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); if ( is_string( $self ) && strtolower( ltrim( $self, 'www.' ) ) === $host ) { return null; } foreach ( self::source_map() as $needle => $entry ) { $needle = strtolower( $needle ); if ( $host === $needle || substr( $host, - ( strlen( $needle ) + 1 ) ) === '.' . $needle ) { return $entry[0]; } } return null; } /** * The current request path (no query string, capped length). * * @return string */ private function current_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); if ( '' === $path ) { $path = '/'; } if ( strlen( $path ) > 300 ) { $path = substr( $path, 0, 300 ); } return $path; } /** * Record one referral into the aggregate. * * @param string $source Source key. * @param string $path Landing path. */ private function record( $source, $path ) { $data = self::get(); $now = current_time( 'mysql' ); $data['totals'][ $source ] = ( isset( $data['totals'][ $source ] ) ? (int) $data['totals'][ $source ] : 0 ) + 1; $day = current_time( 'Y-m-d' ); $data['by_day'][ $day ] = ( isset( $data['by_day'][ $day ] ) ? (int) $data['by_day'][ $day ] : 0 ) + 1; $data['by_day'] = self::trim_days( $data['by_day'] ); if ( '' !== $path ) { $data['by_page'][ $path ] = ( isset( $data['by_page'][ $path ] ) ? (int) $data['by_page'][ $path ] : 0 ) + 1; $data['by_page'] = self::trim_pages( $data['by_page'] ); } if ( '' === $data['first_seen'] ) { $data['first_seen'] = $now; } $data['last_seen'] = $now; update_option( ADG_AIM_OPT_REFERRALS, $data, false ); } /* ------------------------------------------------------------------ */ /** * Grand total across all sources. * * @param array $data Aggregate. * @return int */ public static function total( array $data ) { $t = isset( $data['totals'] ) && is_array( $data['totals'] ) ? $data['totals'] : array(); return (int) array_sum( array_map( 'intval', $t ) ); } /** * Totals per source, sorted desc, with labels. * * @param array $data Aggregate. * @return array list of array( key, label, count ). */ public static function by_source( array $data ) { $labels = self::source_labels(); $totals = isset( $data['totals'] ) && is_array( $data['totals'] ) ? $data['totals'] : array(); arsort( $totals ); $out = array(); foreach ( $totals as $key => $count ) { $out[] = array( 'key' => $key, 'label' => isset( $labels[ $key ] ) ? $labels[ $key ] : ucfirst( $key ), 'count' => (int) $count, ); } return $out; } /** * "Last N days" series 'Y-m-d' => count (with zeros), TZ-stable. * * @param array $data Aggregate. * @param int $days Days back. * @return array */ public static function daily_series( array $data, $days = 30 ) { $days = max( 1, (int) $days ); $by_day = isset( $data['by_day'] ) && is_array( $data['by_day'] ) ? $data['by_day'] : array(); $tz = new DateTimeZone( 'UTC' ); $today = DateTimeImmutable::createFromFormat( 'Y-m-d', current_time( 'Y-m-d' ), $tz ); if ( false === $today ) { $today = new DateTimeImmutable( 'now', $tz ); } $series = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $key = $today->modify( "-{$i} days" )->format( 'Y-m-d' ); $series[ $key ] = isset( $by_day[ $key ] ) ? (int) $by_day[ $key ] : 0; } return $series; } /** * Top landing pages by referral count. * * @param array $data Aggregate. * @param int $n Count. * @return array path => count. */ public static function top_pages( array $data, $n = 10 ) { $pages = isset( $data['by_page'] ) && is_array( $data['by_page'] ) ? $data['by_page'] : array(); arsort( $pages ); return array_slice( $pages, 0, max( 1, (int) $n ), true ); } /** * Sum of the last N days across all sources. * * @param array $data Aggregate. * @param int $days Days. * @return int */ public static function sum_last_days( array $data, $days = 7 ) { return (int) array_sum( self::daily_series( $data, $days ) ); } /* ------------------------------------------------------------------ */ /** * Trim the by-day breakdown to the last DAYS_KEEP. * * @param array $days 'Y-m-d' => int. * @return array */ private static function trim_days( array $days ) { if ( count( $days ) <= self::DAYS_KEEP ) { return $days; } ksort( $days ); return array_slice( $days, - self::DAYS_KEEP, null, true ); } /** * Shrink landing pages on overflow. * * @param array $pages path => int. * @return array */ private static function trim_pages( array $pages ) { if ( count( $pages ) <= self::PAGES_CAP ) { return $pages; } arsort( $pages ); return array_slice( $pages, 0, self::PAGES_TARGET, true ); } }