array( channel, source ). */ const CLICK_IDS = array( 'gclid' => array( 'paid_search', 'google' ), 'gbraid' => array( 'paid_search', 'google' ), 'wbraid' => array( 'paid_search', 'google' ), 'dclid' => array( 'display', 'google' ), 'msclkid' => array( 'paid_search', 'bing' ), 'yclid' => array( 'paid_search', 'yandex' ), 'ttclid' => array( 'paid_social', 'tiktok' ), 'li_fat_id' => array( 'paid_social', 'linkedin' ), 'twclid' => array( 'paid_social', 'x' ), 'epik' => array( 'paid_social', 'pinterest' ), 'ScCid' => array( 'paid_social', 'snapchat' ), 'rdt_cid' => array( 'paid_social', 'reddit' ), 'qclid' => array( 'paid_social', 'quora' ), 'irclickid' => array( 'affiliate', 'impact' ), ); const UTM_KEYS = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id' ); const SEARCH_ENGINES = array( 'google.', 'bing.com', 'yahoo.', 'duckduckgo.com', 'ecosia.org', 'yandex.', 'ya.ru', 'baidu.com', 'sogou.com', 'so.com', 'sm.cn', 'naver.com', 'daum.net', 'seznam.cz', 'coccoc.com', 'go.mail.ru', 'qwant.com', 'startpage.com', 'search.brave.com', 'ask.com', 'aol.com', 'petalsearch.com', 'kagi.com', 'yep.com', ); const SOCIAL_NETWORKS = array( 'facebook.com', 'fb.com', 'instagram.com', 'messenger.com', 'linkedin.com', 'lnkd.in', 't.co', 'twitter.com', 'x.com', 'threads.net', 'bsky.app', 'tiktok.com', 'douyin.com', 'youtube.com', 'pinterest.', 'reddit.com', 'quora.com', 'snapchat.com', 'discord.com', 'xing.com', 'vk.com', 'ok.ru', 'telegram.org', 't.me', 'whatsapp.com', 'wa.me', 'viber.com', 'line.me', 'kakao.com', 'weibo.com', 'weixin.qq.com', 'zhihu.com', 'xiaohongshu.com', 'bilibili.com', 'mastodon.social', ); const AI_ASSISTANTS = array( 'chatgpt.com', 'chat.openai.com', 'perplexity.ai', 'claude.ai', 'gemini.google.com', 'copilot.microsoft.com', 'meta.ai', 'chat.deepseek.com', 'grok.com', 'chat.mistral.ai', 'you.com', 'poe.com', 'kimi.com', 'doubao.com', ); const SOCIAL_SOURCES = array( 'fb', 'ig', 'facebook', 'instagram', 'meta', 'linkedin', 'tiktok', 'pinterest', 'youtube', 'twitter', 'x', 'snapchat', 'reddit', 'quora', 'vk', 'telegram', 'whatsapp', 'line', 'kakao', 'wechat', 'weibo', 'xing' ); /** * Reads attribution for the current request. * * @return array{first: array, last: array}|null */ public static function from_request() { if ( empty( $_COOKIE[ self::COOKIE ] ) ) { return null; } // Sanitized key by key in sanitize_touch(). $raw = wp_unslash( $_COOKIE[ self::COOKIE ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $data = json_decode( rawurldecode( (string) $raw ), true ); if ( ! is_array( $data ) || empty( $data['f'] ) || ! is_array( $data['f'] ) ) { return null; } $first = self::sanitize_touch( $data['f'] ); $last = isset( $data['l'] ) && is_array( $data['l'] ) ? self::sanitize_touch( $data['l'] ) : $first; $attribution = array( 'first' => $first, 'last' => $last, ); $journey = self::journey_from_request(); if ( $journey ) { $attribution['journey'] = $journey; } // Kind of device, system and browser of the enquiry; the user agent itself is not kept. $device = Devices::from_request(); if ( $device ) { $attribution['device'] = $device; } // Time zone of the browser, for the country when nothing more exact is known. $zone = isset( $data['z'] ) && is_string( $data['z'] ) ? mb_substr( sanitize_text_field( $data['z'] ), 0, 60 ) : ''; if ( preg_match( '#^[A-Za-z]+(?:/[A-Za-z0-9_+\-]+){1,2}$#', $zone ) ) { $attribution['tz'] = $zone; } return $attribution; } /** * Visit count, first visit and recent pages from the journey cookie. * * @return array|null n (visits), f (first visit timestamp), p (list of path and timestamp). */ public static function journey_from_request() { if ( empty( $_COOKIE[ self::JOURNEY_COOKIE ] ) ) { return null; } // Sanitized field by field below. $raw = wp_unslash( $_COOKIE[ self::JOURNEY_COOKIE ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $data = json_decode( rawurldecode( (string) $raw ), true ); if ( ! is_array( $data ) || empty( $data['f'] ) ) { return null; } $pages = array(); foreach ( array_slice( (array) ( $data['p'] ?? array() ), -15 ) as $page ) { if ( is_array( $page ) && isset( $page[0], $page[1] ) && is_scalar( $page[0] ) ) { $pages[] = array( mb_substr( sanitize_text_field( (string) $page[0] ), 0, 80 ), absint( $page[1] ) ); } } return array( 'n' => min( 10000, max( 1, absint( $data['n'] ?? 1 ) ) ), 'f' => absint( $data['f'] ), 'p' => $pages, ); } /** * All keys the tracker may store. * * @return string[] */ public static function keys() { return array_merge( self::UTM_KEYS, array_keys( self::CLICK_IDS ), array( 'fbclid', 'ref', 'lp', 'ts' ) ); } /** * Keeps known keys and strips unsafe content. * * @param array $touch Raw touch. * @return array */ public static function sanitize_touch( array $touch ) { $clean = array(); foreach ( self::keys() as $key ) { if ( ! isset( $touch[ $key ] ) || '' === $touch[ $key ] || is_array( $touch[ $key ] ) ) { continue; } if ( 'ts' === $key ) { $clean['ts'] = absint( $touch['ts'] ); continue; } $max = in_array( $key, array( 'lp', 'ref' ), true ) ? 300 : 200; $value = mb_substr( sanitize_text_field( (string) $touch[ $key ] ), 0, $max ); // Click IDs are passed on to ad platforms and their upload files; real ones are plain tokens. if ( ( isset( self::CLICK_IDS[ $key ] ) || 'fbclid' === $key ) && ! preg_match( '/^[A-Za-z0-9_\-.~][A-Za-z0-9_\-.~=]*$/', $value ) ) { continue; } $clean[ $key ] = $value; } return $clean; } /** * Builds the column values stored with a lead. * * @param array|null $attribution Output of from_request(). * @return array */ public static function to_columns( $attribution ) { if ( empty( $attribution['first'] ) ) { return array( 'channel' => '', 'last_channel' => '', 'attribution' => '', ); } $first = $attribution['first']; $last = $attribution['last']; $columns = array( 'channel' => self::channel( $first ), 'source' => self::source( $first ), 'medium' => self::medium( $first ), 'campaign' => $first['utm_campaign'] ?? '', 'last_channel' => self::channel( $last ), 'last_source' => self::source( $last ), 'last_medium' => self::medium( $last ), 'last_campaign' => $last['utm_campaign'] ?? '', 'landing_page' => $first['lp'] ?? '', 'attribution' => wp_json_encode( $attribution ), ); // Click IDs come from the most recent touch that carried one, which is what ad platforms expect. foreach ( array( 'gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid' ) as $click_id ) { $columns[ $click_id ] = $last[ $click_id ] ?? ( $first[ $click_id ] ?? '' ); } return $columns; } /** * Channel group for a touch. * * @param array $touch Sanitized touch. * @return string */ public static function channel( array $touch ) { $medium = strtolower( $touch['utm_medium'] ?? '' ); $source = strtolower( $touch['utm_source'] ?? '' ); $ref = strtolower( $touch['ref'] ?? '' ); foreach ( self::CLICK_IDS as $param => $info ) { if ( ! empty( $touch[ $param ] ) ) { return $info[0]; } } $is_paid = (bool) preg_match( '/^(cpc|ppc|cpm|cpv|cpa|paid.*|.*_paid|.*-paid|sem)$/', $medium ); $is_social = self::matches( $source, self::SOCIAL_NETWORKS ) || in_array( $source, self::SOCIAL_SOURCES, true ) || false !== strpos( $medium, 'social' ); if ( $is_paid && $is_social ) { return 'paid_social'; } if ( $is_paid && in_array( $medium, array( 'cpm', 'cpv' ), true ) ) { return 'display'; } if ( $is_paid ) { return 'paid_search'; } if ( in_array( $medium, array( 'display', 'banner', 'programmatic' ), true ) ) { return 'display'; } if ( in_array( $medium, array( 'email', 'e-mail', 'e_mail', 'newsletter' ), true ) ) { return 'email'; } if ( 'affiliate' === $medium ) { return 'affiliate'; } if ( $is_social || ! empty( $touch['fbclid'] ) || self::matches( $ref, self::SOCIAL_NETWORKS ) ) { return 'organic_social'; } if ( self::matches( $ref, self::AI_ASSISTANTS ) || self::matches( $source, self::AI_ASSISTANTS ) ) { return 'ai_assistant'; } if ( 'organic' === $medium || self::matches( $ref, self::SEARCH_ENGINES ) ) { return 'organic_search'; } if ( '' !== $source || '' !== $medium ) { return 'other'; } if ( '' !== $ref ) { return 'referral'; } return 'direct'; } /** * Source name for a touch. * * @param array $touch Sanitized touch. * @return string */ public static function source( array $touch ) { if ( ! empty( $touch['utm_source'] ) ) { return $touch['utm_source']; } foreach ( self::CLICK_IDS as $param => $info ) { if ( ! empty( $touch[ $param ] ) ) { return $info[1]; } } if ( ! empty( $touch['fbclid'] ) ) { return 'facebook'; } if ( ! empty( $touch['ref'] ) ) { $host = preg_replace( '/^(www|m|l|lm|mobile)\./', '', (string) strtok( $touch['ref'], '/' ) ); if ( self::matches( $host, self::AI_ASSISTANTS ) ) { return $host; } // Search engines and social networks are named like in analytics tools: google, not google.de. foreach ( array( self::SEARCH_ENGINES, self::SOCIAL_NETWORKS ) as $list ) { foreach ( $list as $needle ) { if ( self::matches( $host, array( $needle ) ) ) { return self::network_name( $needle ); } } } return $host; } return '(direct)'; } /** * Short name of a search engine or social network. * * @param string $needle Entry from SEARCH_ENGINES or SOCIAL_NETWORKS. * @return string */ private static function network_name( $needle ) { $aliases = array( 't.co' => 'x', 'twitter.com' => 'x', 'lnkd.in' => 'linkedin', 'fb.com' => 'facebook', 'wa.me' => 'whatsapp', 't.me' => 'telegram', 'telegram.org' => 'telegram', 'ya.ru' => 'yandex', 'go.mail.ru' => 'mail.ru', 'search.brave.com' => 'brave', 'weixin.qq.com' => 'wechat', 'line.me' => 'line', 'ok.ru' => 'ok', 'vk.com' => 'vk', 'so.com' => '360', 'sm.cn' => 'shenma', 'bsky.app' => 'bluesky', ); return $aliases[ $needle ] ?? strtok( $needle, '.' ); } /** * Medium for a touch. * * @param array $touch Sanitized touch. * @return string */ public static function medium( array $touch ) { if ( ! empty( $touch['utm_medium'] ) ) { return $touch['utm_medium']; } switch ( self::channel( $touch ) ) { case 'paid_search': return 'cpc'; case 'organic_search': return 'organic'; case 'organic_social': return 'social'; case 'referral': case 'ai_assistant': return 'referral'; default: return '(none)'; } } /** * Attribution rows for notification emails and messages. * * @param array $columns Output of to_columns() or a stored lead row as array. * @return array Label => value. */ public static function summary_rows( array $columns ) { $rows = array( __( 'Channel', 'dealroot' ) => self::channel_label( $columns['channel'] ?? '' ), __( 'Source', 'dealroot' ) => $columns['source'] ?? '', __( 'Medium', 'dealroot' ) => $columns['medium'] ?? '', __( 'Campaign', 'dealroot' ) => $columns['campaign'] ?? '', __( 'Landing page', 'dealroot' ) => $columns['landing_page'] ?? '', ); if ( ! empty( $columns['last_channel'] ) && ( $columns['channel'] ?? '' ) !== $columns['last_channel'] ) { $rows[ __( 'Last channel', 'dealroot' ) ] = self::channel_label( $columns['last_channel'] ); } return array_filter( $rows, 'strlen' ); } /** * Plain-text attribution block for the current request. * * @return string */ public static function summary_text() { $lines = array(); foreach ( self::summary_rows( self::to_columns( self::from_request() ) ) as $label => $value ) { $lines[] = $label . ': ' . $value; } return implode( "\n", $lines ); } /** * Human-readable channel labels. * * @return array */ public static function channel_labels() { return array( 'paid_search' => __( 'Paid search', 'dealroot' ), 'paid_social' => __( 'Paid social', 'dealroot' ), 'display' => __( 'Display', 'dealroot' ), 'organic_search' => __( 'Organic search', 'dealroot' ), 'organic_social' => __( 'Organic social', 'dealroot' ), 'ai_assistant' => __( 'AI assistants', 'dealroot' ), 'email' => __( 'Email', 'dealroot' ), 'affiliate' => __( 'Affiliate', 'dealroot' ), 'referral' => __( 'Referral', 'dealroot' ), 'direct' => __( 'Direct', 'dealroot' ), 'other' => __( 'Other campaigns', 'dealroot' ), 'phone_call' => __( 'Phone call', 'dealroot' ), 'walk_in' => __( 'Walk-in', 'dealroot' ), 'word_of_mouth' => __( 'Word of mouth', 'dealroot' ), '' => __( 'Not tracked', 'dealroot' ), ); } /** * Label for one channel key. * * @param string $channel Channel key. * @return string */ public static function channel_label( $channel ) { $labels = self::channel_labels(); return $labels[ $channel ] ?? $channel; } /** * Whether a host or name contains one of the needles. * * @param string $haystack Value to check. * @param array $needles Fragments. * @return bool */ private static function matches( $haystack, array $needles ) { if ( '' === $haystack ) { return false; } $host = '.' . strtok( $haystack, '/' ); foreach ( $needles as $needle ) { // "google." matches any Google TLD; full domains match themselves and their subdomains. if ( '.' === substr( $needle, -1 ) ) { if ( false !== strpos( $host, '.' . $needle ) ) { return true; } } elseif ( substr( $host, -strlen( $needle ) - 1 ) === '.' . $needle ) { return true; } } return false; } }