Files
coruna-lab/app/Support/UserAgentParser.php
T
2026-08-24 06:23:07 +08:00

146 lines
4.5 KiB
PHP

<?php
namespace App\Support;
/**
* Lightweight UA → OS / browser fields for page-visit grouping.
*/
final class UserAgentParser
{
/**
* @return array{os: string, os_version: string, browser: string, browser_version: string}
*/
public static function parse(?string $ua): array
{
$ua = trim((string) $ua);
if ($ua === '') {
return [
'os' => 'Unknown',
'os_version' => '',
'browser' => 'Unknown',
'browser_version' => '',
];
}
[$os, $osVersion] = self::parseOs($ua);
[$browser, $browserVersion] = self::parseBrowser($ua);
// iOS 26+ Safari still reports "iPhone OS 18_7" for compatibility.
// When Version/ is 26+ and the OS token is 18.7, use Version/ for both fields.
$compatVersion = self::ios26CompatVersion($ua);
if ($compatVersion !== null && self::isIos187CompatToken($os, $osVersion)) {
$osVersion = $compatVersion;
$browserVersion = $compatVersion;
}
return [
'os' => $os,
'os_version' => $osVersion,
'browser' => $browser,
'browser_version' => $browserVersion,
];
}
private static function isIos187CompatToken(string $os, string $osVersion): bool
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return false;
}
return $osVersion === '18.7' || str_starts_with($osVersion, '18.7.');
}
private static function ios26CompatVersion(string $ua): ?string
{
if (! preg_match('/Version\/(\d+(?:\.\d+){0,2})/i', $ua, $m)) {
return null;
}
$major = (int) explode('.', $m[1])[0];
if ($major < 26) {
return null;
}
return $m[1];
}
/**
* @return array{0: string, 1: string}
*/
private static function parseOs(string $ua): array
{
if (preg_match('/iPhone OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)
|| preg_match('/CPU OS (\d+)[._](\d+)(?:[._](\d+))?.*like Mac OS X/i', $ua, $m)) {
$ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : '');
return ['iOS', $ver];
}
if (preg_match('/iPad.*OS (\d+)[._](\d+)/i', $ua, $m)) {
return ['iPadOS', $m[1].'.'.$m[2]];
}
if (preg_match('/Android (\d+(?:\.\d+){0,2})/i', $ua, $m)) {
return ['Android', $m[1]];
}
if (preg_match('/Windows NT (\d+\.\d+)/i', $ua, $m)) {
$map = [
'10.0' => '10/11',
'6.3' => '8.1',
'6.2' => '8',
'6.1' => '7',
];
return ['Windows', $map[$m[1]] ?? $m[1]];
}
if (preg_match('/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) {
$ver = $m[1].'.'.$m[2].(isset($m[3]) && $m[3] !== '' ? '.'.$m[3] : '');
return ['macOS', $ver];
}
if (preg_match('/Linux/i', $ua)) {
return ['Linux', ''];
}
return ['Unknown', ''];
}
/**
* @return array{0: string, 1: string}
*/
private static function parseBrowser(string $ua): array
{
// Order matters: more specific tokens first (iOS Chrome/Firefox/Edge before Safari).
$rules = [
['CriOS', 'Chrome'],
['Chrome', 'Chrome'],
['EdgiOS', 'Edge'],
['Edg/', 'Edge'],
['FxiOS', 'Firefox'],
['Firefox', 'Firefox'],
['OPT/', 'Opera'],
['OPiOS', 'Opera'],
['Opera', 'Opera'],
['SamsungBrowser', 'Samsung'],
['UCBrowser', 'UC'],
['Version/', 'Safari'], // Safari reports Version/x before Safari/
['Safari', 'Safari'],
];
foreach ($rules as [$token, $name]) {
if ($token === 'Version/' && ! preg_match('/Safari/i', $ua)) {
continue;
}
if ($token === 'Safari' && preg_match('/CriOS|FxiOS|EdgiOS|Chrome|Android/i', $ua)) {
continue;
}
$pattern = $token === 'Version/' || str_ends_with($token, '/')
? '/'.preg_quote(rtrim($token, '/'), '/').'\/(\d+(?:\.\d+){0,2})/i'
: '/'.preg_quote($token, '/').'[\/\s]?(\d+(?:\.\d+){0,2})?/i';
if (preg_match($pattern, $ua, $m)) {
return [$name, $m[1] ?? ''];
}
}
return ['Unknown', ''];
}
}