BxAnalyticsSource.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup Analytics Analytics
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. /**
  12. * Traffic source of a session: referrer host, UTM parameters, click ids and the channel they add up to.
  13. */
  14. class BxAnalyticsSource
  15. {
  16. const CHANNEL_DIRECT = 'direct';
  17. const CHANNEL_INTERNAL = 'internal';
  18. const CHANNEL_SEARCH = 'search';
  19. const CHANNEL_PAID_SEARCH = 'paid_search';
  20. const CHANNEL_SOCIAL = 'social';
  21. const CHANNEL_PAID_SOCIAL = 'paid_social';
  22. const CHANNEL_EMAIL = 'email';
  23. const CHANNEL_AI = 'ai';
  24. const CHANNEL_DISPLAY = 'display';
  25. const CHANNEL_AFFILIATE = 'affiliate';
  26. const CHANNEL_CAMPAIGN = 'campaign';
  27. const CHANNEL_REFERRAL = 'referral';
  28. /**
  29. * Host suffixes per channel. A host matches a suffix when it equals it or ends with "." . suffix.
  30. * A suffix ending with "." matches any top level domain (google.com, google.co.uk).
  31. */
  32. protected static $_aHosts = [
  33. self::CHANNEL_AI => [
  34. 'chatgpt.com', 'chat.openai.com', 'openai.com', 'perplexity.ai', 'claude.ai', 'gemini.google.com', 'bard.google.com',
  35. 'copilot.microsoft.com', 'copilot.cloud.microsoft', 'you.com', 'phind.com', 'poe.com', 'chat.deepseek.com', 'meta.ai',
  36. 'grok.com', 'chat.mistral.ai',
  37. ],
  38. self::CHANNEL_SEARCH => [
  39. 'google.', 'bing.com', 'yahoo.', 'duckduckgo.com', 'yandex.', 'ya.ru', 'baidu.com', 'ecosia.org', 'qwant.com',
  40. 'search.brave.com', 'startpage.com', 'naver.com', 'seznam.cz', 'sogou.com', 'so.com', 'ask.com', 'aol.com',
  41. 'kagi.com', 'mojeek.com', 'search.yahoo.co.jp', 'daum.net', 'coccoc.com', 'petalsearch.com',
  42. ],
  43. self::CHANNEL_SOCIAL => [
  44. 'facebook.com', 'fb.com', 'fb.me', 'messenger.com', 'instagram.com', 't.co', 'twitter.com', 'x.com', 'linkedin.com',
  45. 'lnkd.in', 'reddit.com', 'redd.it', 'pinterest.', 'pin.it', 'tiktok.com', 'youtube.com', 'youtu.be', 'vk.com', 'vk.ru',
  46. 'ok.ru', 't.me', 'telegram.org', 'telegram.me', 'whatsapp.com', 'wa.me', 'threads.net', 'threads.com', 'bsky.app',
  47. 'mastodon.social', 'quora.com', 'tumblr.com', 'discord.com', 'discord.gg', 'snapchat.com', 'weibo.com', 'weibo.cn',
  48. 'news.ycombinator.com', 'line.me', 'kakao.com', 'twitch.tv', 'medium.com', 'substack.com', 'nextdoor.com',
  49. 'xing.com', 'flickr.com', 'vimeo.com', 'dribbble.com', 'behance.net', 'stackoverflow.com', 'github.com',
  50. ],
  51. self::CHANNEL_EMAIL => [
  52. 'mail.google.com', 'inbox.google.com', 'outlook.live.com', 'outlook.office.com', 'outlook.office365.com',
  53. 'mail.yahoo.com', 'mail.yandex.ru', 'mail.ru', 'e.mail.ru', 'mail.aol.com', 'mail.proton.me', 'mail.zoho.com',
  54. 'icloud.com', 'fastmail.com', 'gmx.net', 'gmx.com', 'web.de', 't-online.de',
  55. ],
  56. ];
  57. /**
  58. * Click id parameters that ad networks append, with the channel each one means.
  59. */
  60. protected static $_aClickIds = [
  61. 'gclid' => self::CHANNEL_PAID_SEARCH,
  62. 'gbraid' => self::CHANNEL_PAID_SEARCH,
  63. 'wbraid' => self::CHANNEL_PAID_SEARCH,
  64. 'msclkid' => self::CHANNEL_PAID_SEARCH,
  65. 'yclid' => self::CHANNEL_PAID_SEARCH,
  66. 'dclid' => self::CHANNEL_DISPLAY,
  67. 'ttclid' => self::CHANNEL_PAID_SOCIAL,
  68. 'li_fat_id' => self::CHANNEL_PAID_SOCIAL,
  69. 'twclid' => self::CHANNEL_PAID_SOCIAL,
  70. 'sccid' => self::CHANNEL_PAID_SOCIAL,
  71. 'epik' => self::CHANNEL_PAID_SOCIAL,
  72. ];
  73. public static $aUtm = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
  74. /**
  75. * Work out the source of a session from its first page.
  76. *
  77. * @param string $sReferrer document.referrer or the app's referrer, may be empty
  78. * @param string $sUrl the landing page URL with its query string, for UTM parameters and click ids
  79. * @param array $aInternalHosts hosts of the site and its clients: a referrer from one of them is not a source
  80. * @return array with keys referrer (scheme and query stripped), referrer_host, channel and the utm_* keys
  81. */
  82. public static function classify($sReferrer, $sUrl = '', $aInternalHosts = [])
  83. {
  84. $aQuery = self::_query($sUrl);
  85. $aResult = [
  86. 'referrer' => '',
  87. 'referrer_host' => '',
  88. 'channel' => self::CHANNEL_DIRECT,
  89. ];
  90. foreach(self::$aUtm as $sKey)
  91. $aResult[$sKey] = isset($aQuery[$sKey]) && is_string($aQuery[$sKey]) ? self::_clean($aQuery[$sKey], 128) : '';
  92. // ref= / source= are common in links without UTM (Product Hunt, newsletters)
  93. if($aResult['utm_source'] === '')
  94. foreach(['ref', 'source'] as $sKey)
  95. if(isset($aQuery[$sKey]) && is_string($aQuery[$sKey]) && $aQuery[$sKey] !== '') {
  96. $aResult['utm_source'] = self::_clean($aQuery[$sKey], 128);
  97. break;
  98. }
  99. $sHost = self::host($sReferrer);
  100. $bInternal = $sHost !== '' && self::_isInternal($sHost, $aInternalHosts);
  101. if($sHost !== '' && !$bInternal) {
  102. $aResult['referrer_host'] = $sHost;
  103. $aResult['referrer'] = self::_clean(preg_replace(['/^[a-z][a-z0-9+.-]*:\/\/(www\.)?/i', '/[?#].*$/'], '', $sReferrer), 255);
  104. }
  105. $aResult['channel'] = self::channel($aResult['referrer_host'], $aResult['utm_source'], $aResult['utm_medium'], array_keys($aQuery));
  106. if($aResult['channel'] === self::CHANNEL_DIRECT && $bInternal)
  107. $aResult['channel'] = self::CHANNEL_INTERNAL;
  108. return $aResult;
  109. }
  110. /**
  111. * Channel from a referrer host, UTM source and medium, and the landing page's query parameter names.
  112. */
  113. public static function channel($sReferrerHost, $sUtmSource = '', $sUtmMedium = '', $aQueryKeys = [])
  114. {
  115. $sMedium = mb_strtolower(trim($sUtmMedium));
  116. $sSource = mb_strtolower(trim($sUtmSource));
  117. $sSourceChannel = $sSource !== '' ? self::_hostChannel($sSource) : '';
  118. $sReferrerChannel = $sReferrerHost !== '' ? self::_hostChannel($sReferrerHost) : '';
  119. $sKnown = $sSourceChannel ? $sSourceChannel : $sReferrerChannel;
  120. $bPaid = (bool)preg_match('/^(.*cpc|ppc|paid.*|cpm|cpv|cpa|retargeting|sem)$/', $sMedium);
  121. if($bPaid) {
  122. if(preg_match('/social/', $sMedium) || $sKnown === self::CHANNEL_SOCIAL)
  123. return self::CHANNEL_PAID_SOCIAL;
  124. if($sKnown === self::CHANNEL_SEARCH || preg_match('/search|cpc|ppc|sem/', $sMedium))
  125. return self::CHANNEL_PAID_SEARCH;
  126. return self::CHANNEL_DISPLAY;
  127. }
  128. // an explicit medium says more than a click id that an ad network appended
  129. if($sMedium !== '') {
  130. if(preg_match('/^(e[-_ ]?mail|newsletter|mailing)/', $sMedium))
  131. return self::CHANNEL_EMAIL;
  132. if(preg_match('/^(social|social[-_ ]?(network|media)|sm|post)$/', $sMedium))
  133. return self::CHANNEL_SOCIAL;
  134. if(preg_match('/^(display|banner|expandable|interstitial)$/', $sMedium))
  135. return self::CHANNEL_DISPLAY;
  136. if($sMedium === 'affiliate' || $sMedium === 'partner')
  137. return self::CHANNEL_AFFILIATE;
  138. if($sMedium === 'organic')
  139. return $sKnown === self::CHANNEL_SOCIAL ? self::CHANNEL_SOCIAL : self::CHANNEL_SEARCH;
  140. if($sMedium === 'referral')
  141. return $sKnown ? $sKnown : self::CHANNEL_REFERRAL;
  142. }
  143. foreach(self::$_aClickIds as $sKey => $sChannel)
  144. if(in_array($sKey, $aQueryKeys, true))
  145. return $sChannel;
  146. if($sKnown)
  147. return $sKnown;
  148. if($sSource !== '' || $sMedium !== '')
  149. return self::CHANNEL_CAMPAIGN;
  150. return $sReferrerHost !== '' ? self::CHANNEL_REFERRAL : self::CHANNEL_DIRECT;
  151. }
  152. /**
  153. * Lower case host of a URL without "www.", or '' when there is none. Android apps send
  154. * referrers like android-app://com.google.android.gm, which become the package name.
  155. */
  156. public static function host($sUrl)
  157. {
  158. $sUrl = trim((string)$sUrl);
  159. if($sUrl === '')
  160. return '';
  161. $sHost = parse_url(bx_mb_strpos($sUrl, '//') === false ? '//' . $sUrl : $sUrl, PHP_URL_HOST);
  162. if(!is_string($sHost) || $sHost === '')
  163. return '';
  164. $sHost = mb_strtolower(rtrim($sHost, '.'));
  165. if(bx_mb_strpos($sHost, 'www.') === 0)
  166. $sHost = get_mb_substr($sHost, 4, get_mb_len($sHost));
  167. return preg_match('/^[a-z0-9.-]{1,253}$/', $sHost) ? $sHost : '';
  168. }
  169. protected static function _hostChannel($sHost)
  170. {
  171. $sHost = mb_strtolower($sHost);
  172. // Android app referrers
  173. $aApps = [
  174. 'com.google.android.gm' => self::CHANNEL_EMAIL,
  175. 'com.google.android.googlequicksearchbox' => self::CHANNEL_SEARCH,
  176. 'com.facebook.katana' => self::CHANNEL_SOCIAL,
  177. 'com.instagram.android' => self::CHANNEL_SOCIAL,
  178. 'com.twitter.android' => self::CHANNEL_SOCIAL,
  179. 'com.linkedin.android' => self::CHANNEL_SOCIAL,
  180. 'com.reddit.frontpage' => self::CHANNEL_SOCIAL,
  181. 'org.telegram.messenger' => self::CHANNEL_SOCIAL,
  182. 'com.whatsapp' => self::CHANNEL_SOCIAL,
  183. 'com.openai.chatgpt' => self::CHANNEL_AI,
  184. ];
  185. if(isset($aApps[$sHost]))
  186. return $aApps[$sHost];
  187. foreach(self::$_aHosts as $sChannel => $aSuffixes)
  188. foreach($aSuffixes as $sSuffix) {
  189. if(bx_mb_strpos($sSuffix, '/') !== false)
  190. continue;
  191. if(get_mb_substr($sSuffix, -1, 1) === '.') {
  192. // google. matches google.com, google.co.uk, news.google.com
  193. if(preg_match('/(^|\.)' . preg_quote($sSuffix, '/') . '[a-z]{2,3}(\.[a-z]{2})?$/', $sHost))
  194. return $sChannel;
  195. continue;
  196. }
  197. if($sHost === $sSuffix || self::_endsWith($sHost, '.' . $sSuffix))
  198. return $sChannel;
  199. }
  200. // a bare UTM source like "google", "facebook" or "newsletter"
  201. if(bx_mb_strpos($sHost, '.') === false) {
  202. if(preg_match('/^(google|bing|yahoo|duckduckgo|yandex|baidu|ecosia|naver|brave)$/', $sHost))
  203. return self::CHANNEL_SEARCH;
  204. if(preg_match('/^(facebook|fb|instagram|ig|twitter|x|linkedin|reddit|pinterest|tiktok|youtube|vk|telegram|whatsapp|threads|bluesky|mastodon|discord|snapchat)$/', $sHost))
  205. return self::CHANNEL_SOCIAL;
  206. if(preg_match('/^(chatgpt|openai|perplexity|claude|gemini|copilot|deepseek|grok)$/', $sHost))
  207. return self::CHANNEL_AI;
  208. if(preg_match('/^(newsletter|email|mail|gmail|outlook)$/', $sHost))
  209. return self::CHANNEL_EMAIL;
  210. }
  211. return '';
  212. }
  213. protected static function _isInternal($sHost, $aInternalHosts)
  214. {
  215. foreach($aInternalHosts as $sInternal) {
  216. $sInternal = self::host($sInternal);
  217. if($sInternal !== '' && ($sHost === $sInternal || self::_endsWith($sHost, '.' . $sInternal)))
  218. return true;
  219. }
  220. return false;
  221. }
  222. protected static function _endsWith($s, $sEnd)
  223. {
  224. $iLength = get_mb_len($sEnd);
  225. return get_mb_len($s) >= $iLength && get_mb_substr($s, -$iLength, $iLength) === $sEnd;
  226. }
  227. protected static function _query($sUrl)
  228. {
  229. $sQuery = parse_url((string)$sUrl, PHP_URL_QUERY);
  230. if(!is_string($sQuery) || $sQuery === '')
  231. return [];
  232. $aQuery = [];
  233. parse_str($sQuery, $aQuery);
  234. return $aQuery;
  235. }
  236. protected static function _clean($s, $iMaxLength)
  237. {
  238. $s = trim(preg_replace('/[\x00-\x1F\x7F]+/u', ' ', (string)$s));
  239. return get_mb_substr($s, 0, $iMaxLength);
  240. }
  241. }
  242. /** @} */