'meta', 'igshid' => 'instagram', 'gclid' => 'google', 'gbraid' => 'google', 'wbraid' => 'google', 'msclkid' => 'bing', 'ttclid' => 'tiktok', 'ScCid' => 'snapchat', 'twclid' => 'x', 'li_fat_id' => 'linkedin', 'epik' => 'pinterest', ); /** What utm_source says, as a platform. */ const SOURCE_ALIASES = array( 'fb' => 'facebook', 'facebook' => 'facebook', 'meta' => 'meta', 'ig' => 'instagram', 'instagram' => 'instagram', 'an' => 'facebook', 'sc' => 'snapchat', 'snap' => 'snapchat', 'snapchat' => 'snapchat', 'tiktok' => 'tiktok', 'tt' => 'tiktok', 'google' => 'google', 'adwords' => 'google', 'googleads' => 'google', 'bing' => 'bing', 'yahoo' => 'yahoo', 'twitter' => 'x', 'x' => 'x', 'linkedin' => 'linkedin', 'pinterest' => 'pinterest', 'youtube' => 'youtube', 'yt' => 'youtube', 'whatsapp' => 'whatsapp', 'wa' => 'whatsapp', 'telegram' => 'telegram', 'reddit' => 'reddit', 'messenger' => 'facebook', ); /** Referring hosts (without www.) and the platform they are. A host matches itself and its subdomains. */ const HOSTS = array( 'facebook.com' => 'facebook', 'fb.com' => 'facebook', 'fb.me' => 'facebook', 'messenger.com' => 'facebook', 'instagram.com' => 'instagram', 'snapchat.com' => 'snapchat', 'tiktok.com' => 'tiktok', 'twitter.com' => 'x', 't.co' => 'x', 'x.com' => 'x', 'linkedin.com' => 'linkedin', 'lnkd.in' => 'linkedin', 'pinterest.com' => 'pinterest', 'youtube.com' => 'youtube', 'youtu.be' => 'youtube', 'reddit.com' => 'reddit', 'whatsapp.com' => 'whatsapp', 'wa.me' => 'whatsapp', 't.me' => 'telegram', 'telegram.org' => 'telegram', 'google.com' => 'google', 'bing.com' => 'bing', 'duckduckgo.com' => 'duckduckgo', 'yahoo.com' => 'yahoo', 'yandex.com' => 'yandex', 'yandex.ru' => 'yandex', 'baidu.com' => 'baidu', 'ecosia.org' => 'ecosia', 'search.brave.com' => 'brave', 'mail.google.com' => 'email', 'outlook.live.com' => 'email', 'outlook.office.com' => 'email', 'mail.yahoo.com' => 'email', ); const SOCIAL = array( 'facebook', 'meta', 'instagram', 'snapchat', 'tiktok', 'x', 'linkedin', 'pinterest', 'youtube', 'reddit', 'whatsapp', 'telegram' ); const SEARCH = array( 'google', 'bing', 'duckduckgo', 'yahoo', 'yandex', 'baidu', 'ecosia', 'brave' ); public static function init() { add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_script' ) ); } /** The cookie script, on the public pages only. A store with a consent plugin can switch it off with the filter. */ public static function enqueue_script() { if ( is_admin() || ! apply_filters( 'winnyplus_connect_capture_attribution', true ) ) { return; } wp_enqueue_script( 'winnyplus-connect-attribution', WINNYPLUS_CONNECT_PLUGIN_URL . 'assets/js/attribution.js', array(), WINNYPLUS_CONNECT_VERSION, false ); } /** * The visitor's source as the cart events carry it: where this visit (or the last one that came from somewhere) came from, and where * the very first came from. Empty when the browser has told us nothing. * * @return array{traffic_source?: array, first_touch?: array} */ public static function for_visitor() { $cookie = self::read_cookie(); if ( ! $cookie ) { $cookie = self::read_woocommerce_cookies(); // our script did not run (a cache, a blocker, a consent plugin): WooCommerce's own cookies stand in } if ( ! $cookie ) { return array(); } $last = $cookie['l'] ?? ( $cookie['f'] ?? null ); $first = $cookie['f'] ?? null; $out = array(); if ( $last ) { $out['traffic_source'] = self::classify( $last ); } if ( $first && $first !== $last ) { $out['first_touch'] = self::classify( $first ); } return $out; } /** Called when an order is created (the visitor's browser is making the request): the order keeps the source. */ public static function remember_on_order( $order ) { if ( ! $order ) { return; } $source = self::for_visitor(); if ( $source ) { $order->update_meta_data( self::ORDER_META, wp_json_encode( $source ) ); $order->save_meta_data(); } } /** * What an order says about where it came from: what it kept, else WooCommerce's own order attribution. * * @return array{traffic_source?: array, first_touch?: array} */ public static function for_order( $order ) { $kept = json_decode( (string) ( $order->get_meta( self::ORDER_META ) ? $order->get_meta( self::ORDER_META ) : $order->get_meta( '_wpc_traffic_source' ) ), true ); // the old key: orders made before 3.15 if ( is_array( $kept ) && ! empty( $kept['traffic_source'] ) ) { return array_intersect_key( $kept, array( 'traffic_source' => 1, 'first_touch' => 1 ) ); } $touch = self::touch_from_woocommerce( $order ); return $touch ? array( 'traffic_source' => self::classify( $touch ) ) : array(); } /** A touch made out of the order attribution WooCommerce (8.5+) records itself; null for an order made another way or with none. */ private static function touch_from_woocommerce( $order ) { $type = (string) $order->get_meta( '_wc_order_attribution_source_type' ); if ( '' === $type || 'admin' === $type ) { return null; } $params = array(); foreach ( array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term' ) as $key ) { $value = (string) $order->get_meta( '_wc_order_attribution_' . $key ); if ( '' !== $value && '(none)' !== $value ) { $params[ $key ] = substr( $value, 0, 100 ); } } // The page the visit started on carries the click ids $entry = (string) $order->get_meta( '_wc_order_attribution_session_entry' ); parse_str( (string) wp_parse_url( $entry, PHP_URL_QUERY ), $entry_query ); foreach ( array_merge( self::AD_CLICK_IDS, self::MIXED_CLICK_IDS, array( 'igshid' ) ) as $id ) { if ( ! empty( $entry_query[ $id ] ) ) { $params[ $id ] = substr( (string) $entry_query[ $id ], 0, 100 ); } } $referrer = (string) wp_parse_url( (string) $order->get_meta( '_wc_order_attribution_referrer' ), PHP_URL_HOST ); $touch = array( 'p' => $params, 'r' => self::bare_host( $referrer ), 'a' => '', 'l' => (string) wp_parse_url( $entry, PHP_URL_PATH ) ); $created = $order->get_date_created(); if ( $created ) { $touch['t'] = $created->getTimestamp(); } return $touch; } /** * What one touch means. * * @param array $touch { t: seconds, p: parameters, r: referring host, a: app, l: landing path } * @return array{channel: string, platform: ?string, ad: ?bool, ...} */ public static function classify( array $touch ) { $params = isset( $touch['p'] ) && is_array( $touch['p'] ) ? $touch['p'] : array(); $source = strtolower( trim( (string) ( $params['utm_source'] ?? '' ) ) ); $medium = strtolower( trim( (string) ( $params['utm_medium'] ?? '' ) ) ); $ref_host = self::bare_host( (string) ( $touch['r'] ?? '' ) ); $app = strtolower( (string) ( $touch['a'] ?? '' ) ); $click_id = null; foreach ( array_merge( self::AD_CLICK_IDS, self::MIXED_CLICK_IDS, array( 'igshid' ) ) as $id ) { if ( ! empty( $params[ $id ] ) ) { $click_id = $id; break; } } // The platform: what the campaign link says, else the site it came from, else the app it was opened in, else what the click id belongs to $platform = self::SOURCE_ALIASES[ $source ] ?? self::platform_of_host( $ref_host ); if ( ! $platform && $app ) { $platform = $app; } if ( ! $platform && $click_id ) { $platform = self::CLICK_ID_PLATFORM[ $click_id ]; } // A click id of Meta is Facebook or Instagram: the site or the app says which, and only then is "meta" narrowed if ( 'meta' === $platform && ( 'facebook' === $app || 'instagram' === $app ) ) { $platform = $app; } $is_email = 'email' === $medium || 'e-mail' === $medium || 'newsletter' === $source || 'email' === $source || 'email' === $platform; if ( $is_email ) { $platform = 'email' === $platform ? ( $ref_host ?: null ) : $platform; } if ( ! $platform && '' !== $source ) { $platform = preg_replace( '/[^a-z0-9._-]/', '', $source ); } if ( ! $platform && '' !== $ref_host ) { $platform = $ref_host; } // Is it an ad? An ad click id or a paid medium says so. On a social platform nothing else says either way: Meta's click id is on // every click, ads or not, and an app or a tagged link can be either, so those stay unknown (null). It is "not an ad" only with evidence // (a click from the platform's own site without any tag, an organic medium, a shared link) or when the source is not a social platform at all. $ad_signal = (bool) array_intersect( self::AD_CLICK_IDS, array_keys( array_filter( $params ) ) ) || (bool) preg_match( '/^(cpc|ppc|cpm|cpv|paid|paid[-_ ]?(social|search|media)|paidsocial|display|ads?|sponsored|promoted|retarget\w*|remarket\w*|banner)$/', $medium ); $organic_medium = (bool) preg_match( '/^(organic|social|social[-_ ]?media|post|story|bio|link|referral|share)$/', $medium ); if ( $ad_signal ) { $ad = true; } elseif ( ! self::in_list( $platform, self::SOCIAL ) ) { $ad = false; } elseif ( $organic_medium || 'igshid' === $click_id || ( '' !== $ref_host && ! in_array( $click_id, self::MIXED_CLICK_IDS, true ) && '' === $medium && '' === $source ) ) { $ad = false; } else { $ad = null; } if ( $is_email ) { $channel = 'email'; } elseif ( $ad_signal ) { $channel = self::in_list( $platform, self::SOCIAL ) ? 'paid_social' : ( self::in_list( $platform, self::SEARCH ) ? 'paid_search' : 'paid_other' ); } elseif ( null === $ad ) { $channel = 'social'; // from a social platform, with nothing that says whether it was an ad } elseif ( self::in_list( $platform, self::SOCIAL ) ) { $channel = 'organic_social'; } elseif ( self::in_list( $platform, self::SEARCH ) ) { $channel = 'organic_search'; } elseif ( $platform ) { $channel = 'referral'; } else { $channel = 'direct'; } if ( 'direct' === $channel ) { $ad = null; // nothing to say about an ad } $out = array( 'channel' => $channel, 'platform' => $platform ?: null, 'ad' => $ad, ); $extra = array( 'medium' => $medium, 'campaign' => (string) ( $params['utm_campaign'] ?? '' ), 'content' => (string) ( $params['utm_content'] ?? '' ), 'term' => (string) ( $params['utm_term'] ?? '' ), 'utm_source' => $source, 'click_id' => (string) $click_id, 'app' => $app, 'referrer_host' => $ref_host, 'landing_path' => (string) ( $touch['l'] ?? '' ), ); foreach ( $extra as $key => $value ) { if ( '' !== $value ) { $out[ $key ] = $value; } } if ( ! empty( $touch['t'] ) ) { $out['captured_at'] = gmdate( 'c', (int) $touch['t'] ); } return $out; } private static function in_list( $platform, $list ) { return $platform && in_array( $platform, $list, true ); } private static function bare_host( $host ) { return preg_replace( '/^www\./', '', strtolower( trim( (string) $host ) ) ); } /** The platform of a host: an exact match, or the host is a subdomain of a known one (l.facebook.com, m.facebook.com, www.google.com.eg). */ private static function platform_of_host( $host ) { if ( '' === $host ) { return null; } $hosts = self::HOSTS; uksort( $hosts, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); // the most specific host first (mail.google.com before google.com) } ); foreach ( $hosts as $known => $platform ) { if ( $host === $known || substr( $host, -strlen( $known ) - 1 ) === '.' . $known ) { return $platform; } } // Google and Bing have a domain per country (google.com.eg, google.co.uk) if ( preg_match( '/(^|\.)google\.[a-z.]{2,6}$/', $host ) ) { return 'google'; } return null; } /** The cookie's content, kept only when it looks like what the script writes. */ private static function read_cookie() { if ( empty( $_COOKIE[ self::COOKIE ] ) || ! is_string( $_COOKIE[ self::COOKIE ] ) ) { return null; } $raw = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) ); // the JSON is checked and cleaned again, key by key, below if ( strlen( $raw ) > 4000 ) { return null; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { return null; } $clean = array(); foreach ( array( 'f', 'l' ) as $key ) { if ( isset( $data[ $key ] ) && is_array( $data[ $key ] ) ) { $clean[ $key ] = self::clean_touch( $data[ $key ] ); } } return $clean ? $clean : null; } /** * The visit as WooCommerce's order attribution (8.5+) records it in the browser: sbjs_first and sbjs_current say what the visit was * (`typ=utm|src=facebook|mdm=cpc|cmp=...`), sbjs_first_add and sbjs_current_add say where it started (`ep` entry page, `rf` referrer). * Null when the store has none of them. */ private static function read_woocommerce_cookies() { $out = array(); foreach ( array( 'f' => 'first', 'l' => 'current' ) as $key => $name ) { $touch = self::touch_from_sbjs( self::sbjs_values( 'sbjs_' . $name ), self::sbjs_values( 'sbjs_' . $name . '_add' ) ); if ( $touch ) { $out[ $key ] = $touch; } } return $out ? $out : null; } /** One of those cookies as key => value (they are `key=value|key=value`, and may be URL-encoded); empty for a cookie that is not there. */ private static function sbjs_values( $cookie ) { if ( empty( $_COOKIE[ $cookie ] ) || ! is_string( $_COOKIE[ $cookie ] ) || strlen( $_COOKIE[ $cookie ] ) > 2000 ) { return array(); } $values = array(); foreach ( explode( '|', sanitize_text_field( rawurldecode( wp_unslash( $_COOKIE[ $cookie ] ) ) ) ) as $pair ) { $parts = explode( '=', $pair, 2 ); if ( 2 === count( $parts ) && preg_match( '/^[a-z]{2,4}$/', $parts[0] ) ) { $value = trim( $parts[1] ); $values[ $parts[0] ] = in_array( $value, array( '(none)', '(direct)', '(not set)', '(not provided)' ), true ) ? '' : $value; } } return $values; } /** A touch ({ t, p, r, a, l }) from what WooCommerce kept of a visit; null when it kept nothing (a direct visit has nothing to report). */ private static function touch_from_sbjs( array $main, array $add ) { $type = $main['typ'] ?? ''; if ( '' === $type || 'typein' === $type || 'internal' === $type ) { return null; } $params = array(); if ( 'utm' === $type ) { foreach ( array( 'src' => 'utm_source', 'mdm' => 'utm_medium', 'cmp' => 'utm_campaign', 'cnt' => 'utm_content', 'trm' => 'utm_term' ) as $short => $long ) { if ( ! empty( $main[ $short ] ) ) { $params[ $long ] = substr( $main[ $short ], 0, 100 ); } } } $entry = (string) ( $add['ep'] ?? '' ); parse_str( (string) wp_parse_url( $entry, PHP_URL_QUERY ), $query ); foreach ( array_merge( self::AD_CLICK_IDS, self::MIXED_CLICK_IDS, array( 'igshid' ) ) as $id ) { if ( ! empty( $query[ $id ] ) && is_string( $query[ $id ] ) ) { $params[ $id ] = substr( sanitize_text_field( $query[ $id ] ), 0, 100 ); } } $host = self::bare_host( (string) wp_parse_url( (string) ( $add['rf'] ?? '' ), PHP_URL_HOST ) ); if ( '' === $host && in_array( $type, array( 'organic', 'referral' ), true ) ) { $host = self::bare_host( (string) ( $main['src'] ?? '' ) ); // organic and referral visits name the site as their source } $time = strtotime( (string) ( $add['fd'] ?? '' ) ); $touch = self::clean_touch( array( 't' => $time ? $time : 0, 'p' => $params, 'r' => $host, 'a' => '', 'l' => (string) wp_parse_url( $entry, PHP_URL_PATH ) ) ); return $touch['p'] || '' !== $touch['r'] ? $touch : null; } private static function clean_touch( array $touch ) { $params = array(); foreach ( (array) ( $touch['p'] ?? array() ) as $key => $value ) { if ( is_string( $key ) && preg_match( '/^[A-Za-z_]{2,20}$/', $key ) && is_scalar( $value ) ) { $params[ $key ] = substr( sanitize_text_field( (string) $value ), 0, 100 ); } } $host = self::bare_host( sanitize_text_field( (string) ( $touch['r'] ?? '' ) ) ); return array( 't' => (int) ( $touch['t'] ?? 0 ), 'p' => $params, 'r' => preg_match( '/^[a-z0-9.-]{1,100}$/', $host ) ? $host : '', 'a' => preg_match( '/^[a-z]{1,20}$/', (string) ( $touch['a'] ?? '' ) ) ? (string) $touch['a'] : '', 'l' => substr( sanitize_text_field( (string) ( $touch['l'] ?? '' ) ), 0, 200 ), ); } } WinnyPlus_Connect_Attribution::init();