id); * * if ($split->variant('home-cta') === 'b') { * // новый вариант; null — показывайте как сейчас * } * $title = $split->value('home-cta', 'title', 'Как сейчас'); * $split->goal('signup'); * * Вариант считается здесь же, тем же хешем и по той же куке, что и скрипт сайта в браузере: * бэк и фронт видят посетителя в одном варианте — с контрольной группой, выкаткой, группами тестов, бандитом и таргетингом. Правила берутся из готового файла сайта (s/КЛЮЧ.json) * и кэшируются; Сплит недоступен — работают последние сохранённые, а без них посетитель видит сайт как есть. * * Документация: https://ab.pixeltools.ru/docs#server */ final class Split { public const COOKIE = '_ptsab'; public const COOKIE_LIFETIME = 34_560_000; // 400 дней — дольше браузеры куку не хранят public const STICKY_COOKIE = '_ptsab_s'; public const DEFAULT_HOST = 'https://ab.pixeltools.ru'; // Тот же список, что на приёме событий: роботы видят варианты, но в статистику не попадают public const BOTS = '/bot|crawl|spider|slurp|headless|lighthouse|pagespeed|gtmetrix|pingdom|uptime|monitor|preview|facebookexternalhit|embedly|whatsapp|curl\/|wget|python-|java\/|go-http|axios|node-fetch|phantom|selenium|puppeteer|playwright|yandexmetrika/i'; private readonly string $visitorId; private readonly bool $isNew; private readonly bool $known; private readonly bool $user; private readonly string $ref; /** @var array вариант по тестам — считается разом для всех, контрольная группа влияет на следующие */ private ?array $assigned = null; /** @var array */ private array $forced = []; /** @var array соль теста => вариант бандита из куки _ptsab_s */ private array $sticky = []; /** @var array{experiments: list>, endpoint?: string, cookieDomain?: ?string}|null */ private ?array $rules; /** @var list */ private array $queue = []; /** @var array */ private array $exposed = []; /** * @param string $siteKey ключ сайта из кабинета * @param string|null $cookie кука _ptsab из запроса: устройство, где посетитель уже был, остаётся собой * @param string|int|null $userId вошедший пользователь: на его новых устройствах тот же вариант * @param array $query параметры адреса — для предпросмотра ?ab_тест=вариант * @param 'new'|'returning'|null $segment переопределить сегмент посетителя * @param array|null $rules правила целиком — тогда за ними SDK в сеть не ходит * @param (Closure(Closure(): array): array)|null $rulesCache свой кэш правил: * получает загрузчик и возвращает правила; без него — файл во временной папке на $ttl секунд * @param string|null $stickyCookie кука _ptsab_s: варианты бандита, выданные раньше * @param array $attrs свои поля для таргетинга attr:имя — тариф, город… * @param string|null $referer адрес, с которого пришли, если это другой сайт * @param bool $consent посетитель согласился на куки — для сайтов с режимом согласия */ public function __construct( private readonly string $siteKey, ?string $cookie = null, string|int|null $userId = null, private readonly array $query = [], ?string $segment = null, ?array $rules = null, private readonly ?Closure $rulesCache = null, private readonly string $host = self::DEFAULT_HOST, private readonly float $timeout = 1.0, private readonly int $ttl = 60, private readonly ?string $cacheDir = null, ?string $stickyCookie = null, private readonly array $attrs = [], private readonly ?string $userAgent = null, private readonly ?string $acceptLanguage = null, ?string $referer = null, private readonly bool $consent = false, ) { $known = $this->known = $cookie !== null && self::isVisitorId($cookie); $this->user = $userId !== null && $userId !== ''; $this->visitorId = match (true) { $known => $cookie, $this->user => self::userKey($siteKey, (string) $userId), default => self::uuid(), }; $this->isNew = $segment !== null ? $segment === 'new' : !$known; $this->rules = $rules; $this->ref = preg_replace('/^www\./', '', strtolower((string) parse_url((string) $referer, PHP_URL_HOST))); foreach (preg_match('/^[\w.=-]*$/', (string) $stickyCookie) ? explode('.', (string) $stickyCookie) : [] as $pair) { $kv = explode('=', $pair); if (($kv[1] ?? '') !== '') { $this->sticky[$kv[0]] = $kv[1]; } } } /** * Для PHP без фреймворка: кука и адрес берутся из запроса, кука ставится ответом сервера, * показы и цели уходят уже после того, как посетитель получил страницу. * * @param array $options остальные параметры конструктора по именам: host, rules, ttl… */ public static function fromGlobals(string $siteKey, string|int|null $userId = null, array $options = []): self { $cookie = $_COOKIE[self::COOKIE] ?? null; $sticky = $_COOKIE[self::STICKY_COOKIE] ?? null; $referer = $_SERVER['HTTP_REFERER'] ?? null; $host = preg_replace('/:\d+$/', '', (string) ($_SERVER['HTTP_HOST'] ?? '')); $split = new self($siteKey, ...[ 'cookie' => is_string($cookie) ? $cookie : null, 'stickyCookie' => is_string($sticky) ? $sticky : null, 'userId' => $userId, 'query' => $_GET, 'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? null, 'acceptLanguage' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? null, 'referer' => is_string($referer) ? self::externalReferer($referer, $host) : null, ...$options, ]); $c = headers_sent() ? null : $split->cookie(); foreach ($c === null ? [] : array_filter([self::COOKIE => $c['value'], self::STICKY_COOKIE => $c['sticky']]) as $name => $value) { // Без урлэнкода: значение читает скрипт сайта как есть setrawcookie($name, $value, [ 'expires' => time() + $c['maxAge'], 'path' => '/', 'domain' => $c['domain'] ?? '', 'secure' => ($_SERVER['HTTPS'] ?? 'off') !== 'off', 'samesite' => 'Lax', ]); } register_shutdown_function(static function () use ($split): void { if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } $split->flush(); }); return $split; } public function visitorId(): string { return $this->visitorId; } public function isNew(): bool { return $this->isNew; } /** Вариант посетителя; вызов засчитывает показ. null — посетитель не в тесте, теста нет или он не запущен */ public function variant(string $test): ?string { $variant = $this->assignments()[$test] ?? null; $experiment = $this->experiment($test); // Выкаченный вариант и контрольная группа сайта — не тест: показы не считаем if ($variant !== null && !isset($this->exposed[$test]) && empty($experiment['ship']) && ($experiment['kind'] ?? null) !== 'holdout') { $this->exposed[$test] = true; // Предпросмотр по ссылке в статистику не идёт, но подтверждает кабинету, что тест на сайте срабатывает $this->queue[] = isset($this->forced[$test]) ? ['t' => 'check', 'n' => $test] : ['t' => 'exp', 'n' => $test, 'v' => $variant]; } return $variant; } /** Значение варианта из кабинета — текст, цена, флаг; посетителю вне теста — $default. Засчитывает показ */ public function value(string $test, string $field, mixed $default = null): mixed { $variant = $this->variant($test); return $variant === null ? $default : ($this->payload($test, $variant)[$field] ?? $default); } /** * Варианты посетителя во всех тестах сайта — без засчитывания показов, например чтобы передать во фронт. * * @return array */ public function variants(): array { return $this->assignments(); } /** Цель: засчитается, если посетитель уже видел вариант. $value — например сумма заказа */ public function goal(string $name, int|float $value = 1): void { $this->queue[] = ['t' => 'goal', 'n' => $name, 'v' => $value]; } /** Отправить накопленные показы и цели. fromGlobals и SplitServiceProvider вызывают его сами после ответа */ public function flush(): void { // Сплит выключен (Laravel без ключа) — в сеть не ходим if ($this->siteKey === '') { return; } // Сервер шлёт туда же, откуда берёт правила: endpoint из бандла — адрес для браузера $endpoint = rtrim($this->host, '/').'/api/v1/collect'; if ($this->quiet()) { // боту и без согласия — только проверка установки из кабинета $this->queue = array_values(array_filter($this->queue, static fn (array $e): bool => $e['t'] === 'check')); } while ($events = array_splice($this->queue, 0, 100)) { try { $body = json_encode(['k' => $this->siteKey, 'v' => $this->visitorId, 'n' => (int) $this->isNew, 'e' => $events], JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); $this->request('POST', $endpoint, $body); } catch (Throwable) { // Сплит недоступен — сайт работает как обычно, события этого запроса теряются } } } /** * Кука посетителя, которую сайт ставит ответом сервера: такую Safari не урезает до 7 дней. * sticky — значение куки _ptsab_s с вариантами бандита (null — ставить нечего). * null целиком — сайт в режиме согласия, а посетитель его не дал: никаких кук. * * @return array{name: string, value: string, maxAge: int, domain: ?string, sticky: ?string}|null */ public function cookie(): ?array { if (!$this->consented()) { return null; } $this->assignments(); return [ 'name' => self::COOKIE, 'value' => $this->visitorId, 'maxAge' => self::COOKIE_LIFETIME, 'domain' => $this->rules()['cookieDomain'] ?? null, 'sticky' => implode('.', array_map(static fn ($salt, $v): string => "{$salt}={$v}", array_keys($this->sticky), $this->sticky)) ?: null, ]; } /** Реферер — только с другого сайта: переходы внутри сайта не источник; www. не в счёт, как в скрипте сайта */ public static function externalReferer(?string $referer, string $siteHost): ?string { $strip = static fn (?string $host): string => preg_replace('/^www\./', '', strtolower((string) $host)); return $referer !== null && $referer !== '' && $strip(parse_url($referer, PHP_URL_HOST) ?: null) !== $strip($siteHost) ? $referer : null; } public static function isVisitorId(string $value): bool { return (bool) preg_match('/^[\w-]{8,64}$/', $value); } /** ID посетителя из ID пользователя: 64 бита из двух хешей с ключом сайта — сам ID пользователя никуда не уходит */ public static function userKey(string $siteKey, string $userId): string { return sprintf('u%08x%08x', self::hash("{$siteKey}:a:{$userId}"), self::hash("{$siteKey}:b:{$userId}")); } /** * FNV-1a 32 бита + fmix32 из murmur3 — ровно как в скрипте сайта. Скрипт хеширует UTF-16 (charCodeAt), * поэтому не-ASCII строки переводим в него, а 32-битное умножение собираем из половинок, чтобы не уйти в float. */ public static function hash(string $value): int { $units = preg_match('/[^\x00-\x7f]/', $value) ? array_values(unpack('n*', mb_convert_encoding($value, 'UTF-16BE', 'UTF-8')) ?: []) : array_map('ord', $value === '' ? [] : str_split($value)); $h = 0x811c9dc5; foreach ($units as $unit) { $h = self::imul($h ^ $unit, 0x01000193); } $h = self::imul($h ^ ($h >> 16), 0x85ebca6b); $h = self::imul($h ^ ($h >> 13), 0xc2b2ae35); return ($h ^ ($h >> 16)) & 0xFFFFFFFF; } private static function imul(int $a, int $b): int { $a &= 0xFFFFFFFF; return (($a * ($b & 0xFFFF)) + ((($a * ($b >> 16)) & 0xFFFF) << 16)) & 0xFFFFFFFF; } /** Варианты во всех тестах разом и по порядку правил — так же, как скрипт сайта */ private function assignments(): array { if ($this->assigned !== null) { return $this->assigned; } $this->assigned = []; $consent = $this->consented(); $held = false; foreach ($this->rules()['experiments'] as $e) { $keys = array_column($e['variants'], 0); $total = array_sum(array_column($e['variants'], 1)); $forced = $this->query["ab_{$e['key']}"] ?? null; $pick = null; if (is_string($forced) && in_array($forced, $keys, true)) { $pick = $forced; $this->forced[$e['key']] = true; } elseif (!empty($e['preview']) || !$consent || $total < 1) { // черновик — только по ссылке; без согласия на куки посетитель видит сайт как есть } elseif (($e['kind'] ?? null) === 'holdout') { // Контрольная группа сайта: не видит ни тестов, ни выкаченных вариантов $pick = $this->pick($e, $total); $held = $pick === 'holdout'; } elseif ($held) { // в контрольной группе сайта } elseif (($e['ship'] ?? '') !== '') { $pick = $e['ship']; } elseif ($this->targeted($e) && $this->inTraffic($e)) { // Бандит меняет доли на ходу — выданный вариант держим в куке $kept = $this->sticky[$e['salt']] ?? null; $pick = !empty($e['sticky']) && in_array($kept, $keys, true) ? $kept : $this->pick($e, $total); if (!empty($e['sticky']) && $pick !== null) { $this->sticky[$e['salt']] = $pick; } } if ($pick !== null) { $this->assigned[$e['key']] = $pick; } } return $this->assigned; } /** @param array $e */ private function pick(array $e, int $total): ?string { $x = self::hash("{$e['salt']}:v:{$this->visitorId}") % $total; foreach ($e['variants'] as [$key, $weight]) { $x -= $weight; if ($x < 0) { return $key; } } return null; } /** Группа тестов делит корзины 0–99 между тестами, иначе — доля трафика теста */ private function inTraffic(array $e): bool { if (!empty($e['layer'])) { $bucket = self::hash("{$e['layer'][0]}:l:{$this->visitorId}") % 100; return $bucket >= $e['layer'][1] && $bucket < $e['layer'][2]; } return self::hash("{$e['salt']}:t:{$this->visitorId}") % 100 < ($e['traffic'] ?? 0); } /** Все условия теста должны выполниться: [поле, is|not|has, значение] */ private function targeted(array $e): bool { foreach ($e['aud'] ?? [] as [$field, $op, $want]) { $value = mb_strtolower($this->field($field)); $want = mb_strtolower((string) $want); if (!match ($op) { 'not' => $value !== $want, 'has' => str_contains($value, $want), default => $value === $want, }) { return false; } } return true; } private function field(string $name): string { if (str_starts_with($name, 'attr:')) { $value = $this->attrs[substr($name, 5)] ?? null; return is_bool($value) ? ($value ? 'true' : 'false') : (is_scalar($value) ? (string) $value : ''); } if (str_starts_with($name, 'param:')) { return $this->param(substr($name, 6)); } return match ($name) { 'device' => preg_match('/Mobi|Android|iPhone|iPad/i', (string) $this->userAgent) ? 'mobile' : 'desktop', 'source' => $this->param('utm_source') !== '' ? $this->param('utm_source') : ($this->ref !== '' ? $this->ref : 'direct'), 'campaign' => $this->param('utm_campaign'), 'ref' => $this->ref, 'visitor' => $this->isNew ? 'new' : 'returning', 'user' => $this->user ? 'yes' : 'no', 'lang' => substr((string) $this->acceptLanguage, 0, 2), default => '', }; } private function param(string $name): string { $value = $this->query[$name] ?? ''; return is_scalar($value) ? (string) $value : ''; } /** Сайт в режиме согласия: без куки и без согласия посетитель не участвует */ private function consented(): bool { return empty($this->rules()['consent']) || $this->known || $this->consent; } private function quiet(): bool { return !$this->consented() || ($this->userAgent !== null && (bool) preg_match(self::BOTS, $this->userAgent)); } /** @return array|null */ private function experiment(string $test): ?array { foreach ($this->rules()['experiments'] as $experiment) { if ($experiment['key'] === $test) { return $experiment; } } return null; } /** @return array */ private function payload(string $test, string $variant): array { foreach ($this->experiment($test)['variants'] ?? [] as $v) { if ($v[0] === $variant) { return $v[2]['p'] ?? []; } } return []; } /** Правила один раз за запрос: свой кэш или файл на диске; ничего не вышло — тестов нет, сайт работает как есть */ private function rules(): array { try { return $this->rules ??= $this->rulesCache !== null ? ($this->rulesCache)($this->fetchRules(...)) : $this->rulesFromDisk(); } catch (Throwable) { return $this->rules = ['experiments' => []]; } } /** Свежие правила — те, что моложе $ttl; Сплит недоступен — живём на последних и не долбим его каждый запрос */ private function rulesFromDisk(): array { $file = sprintf('%s/ptsab-%s.json', rtrim($this->cacheDir ?? sys_get_temp_dir(), '/'), preg_replace('/\W/', '', $this->siteKey)); if (!is_file($file) || filemtime($file) < time() - $this->ttl) { try { $rules = $this->fetchRules(); self::save($file, $rules); return $rules; } catch (RuntimeException|JsonException) { // Не долбим недоступный Сплит каждый запрос: последние правила (или пустые) ещё $ttl секунд if (!is_file($file)) { self::save($file, $rules = ['experiments' => []]); return $rules; } @touch($file); } } return json_decode((string) file_get_contents($file), true, flags: JSON_THROW_ON_ERROR); } /** Через уникальный временный файл: параллельные запросы не пишут в один */ private static function save(string $file, array $rules): void { $tmp = $file.'.'.getmypid().'.'.uniqid().'.tmp'; if (@file_put_contents($tmp, json_encode($rules, JSON_INVALID_UTF8_SUBSTITUTE)) !== false && !@rename($tmp, $file)) { @unlink($tmp); } } /** @throws RuntimeException|JsonException */ private function fetchRules(): array { $url = sprintf('%s/s/%s.json', rtrim($this->host, '/'), rawurlencode($this->siteKey)); $rules = json_decode($this->request('GET', $url), true, flags: JSON_THROW_ON_ERROR); if (!is_array($rules) || !is_array($rules['experiments'] ?? null)) { throw new RuntimeException("Сплит: неожиданный ответ {$url}"); } return $rules; } /** @throws RuntimeException */ private function request(string $method, string $url, ?string $body = null): string { $context = stream_context_create(['http' => [ 'method' => $method, // Без своего User-Agent приём событий примет запрос за робота 'header' => "Content-Type: text/plain\r\nUser-Agent: PixelTools-Split-PHP/1.1\r\n", 'content' => $body ?? '', 'timeout' => $this->timeout, ]]); $response = @file_get_contents($url, false, $context); return $response !== false ? $response : throw new RuntimeException("Сплит: {$method} {$url} не ответил"); } private static function uuid(): string { $bytes = random_bytes(16); $bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40); $bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80); return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); } }