api: improve user-agent parsing

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-08 14:38:11 +01:00
parent abf5353ab3
commit d3522c51f8
4 changed files with 320 additions and 49 deletions

View File

@@ -16,20 +16,20 @@
"dependencies": {
"@openpanel/constants": "workspace:*",
"date-fns": "^3.3.1",
"lru-cache": "^11.2.2",
"luxon": "^3.6.1",
"lru-cache": "^11.2.4",
"luxon": "^3.7.2",
"mathjs": "^12.3.2",
"nanoid": "^5.0.7",
"nanoid": "^5.1.6",
"ramda": "^0.29.1",
"slugify": "^1.6.6",
"superjson": "^1.13.3",
"ua-parser-js": "^1.0.37",
"ua-parser-js": "^2.0.6",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
"@openpanel/tsconfig": "workspace:*",
"@openpanel/validation": "workspace:*",
"@types/luxon": "^3.6.2",
"@types/luxon": "^3.7.1",
"@types/node": "catalog:",
"@types/ramda": "^0.29.6",
"@types/ua-parser-js": "^0.7.39",

View File

@@ -57,7 +57,7 @@ describe('parseUserAgent', () => {
expect(parseUserAgent(ua)).toEqual({
isServer: false,
device: 'tablet',
os: 'Mac OS',
os: 'iOS',
osVersion: '18.0',
browser: 'WebKit',
browserVersion: '605.1.15',
@@ -70,16 +70,15 @@ describe('parseUserAgent', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
expect(parseUserAgent(ua)).toEqual({
isServer: false,
device: 'desktop',
os: 'Windows',
osVersion: '10',
browser: 'Chrome',
browserVersion: '91.0.4472.124',
brand: undefined,
model: undefined,
});
const result = parseUserAgent(ua);
expect(result.isServer).toBe(false);
expect(result.device).toBe('desktop');
expect(result.os).toBe('Windows');
expect(result.osVersion).toBe('10');
expect(result.browser).toBe('Chrome');
expect(result.browserVersion).toBe('91.0.4472.124');
// Desktop browsers don't have brand/model
expect(result.model).toBeUndefined();
});
it('should handle server user agents', () => {
@@ -167,4 +166,85 @@ describe('getDevice', () => {
expect(getDevice(ua)).toBe('desktop');
});
});
it('should detect known phone models as mobile', () => {
// Xiaomi/Redmi
expect(getDevice('', 'Redmi Note 8 Pro')).toBe('mobile');
expect(getDevice('', 'POCO F3')).toBe('mobile');
expect(getDevice('', 'Mi 11')).toBe('mobile');
// Samsung Galaxy phones
expect(getDevice('', 'Galaxy S23 Ultra')).toBe('mobile');
expect(getDevice('', 'Galaxy A54')).toBe('mobile');
expect(getDevice('', 'Galaxy Z Fold5')).toBe('mobile');
// Google Pixel
expect(getDevice('', 'Pixel 8 Pro')).toBe('mobile');
// OnePlus
expect(getDevice('', 'OnePlus 11')).toBe('mobile');
// Huawei/Honor
expect(getDevice('', 'Huawei P60 Pro')).toBe('mobile');
expect(getDevice('', 'Honor 90')).toBe('mobile');
// OPPO/Vivo/Realme
expect(getDevice('', 'OPPO Find X6')).toBe('mobile');
expect(getDevice('', 'Vivo X90')).toBe('mobile');
expect(getDevice('', 'Realme GT5')).toBe('mobile');
// Motorola
expect(getDevice('', 'Moto G84')).toBe('mobile');
});
});
describe('parseUserAgent - brand detection', () => {
it('should detect Xiaomi brand from Manufacturer field', () => {
const result = parseUserAgent(
'App/1.0 (Android 12; Model=POCO X5; Manufacturer=Xiaomi)',
);
expect(result.brand).toBe('Xiaomi');
expect(result.model).toBe('POCO X5');
expect(result.device).toBe('mobile');
});
it('should detect Samsung brand from model name', () => {
const result = parseUserAgent(
'App/1.0 (Android 13; Model=Galaxy S23 Ultra)',
);
expect(result.brand).toBe('Samsung');
expect(result.model).toBe('Galaxy S23 Ultra');
expect(result.device).toBe('mobile');
});
it('should detect Google Pixel', () => {
const result = parseUserAgent('App/1.0 (Android 14; Model=Pixel 8 Pro)');
expect(result.brand).toBe('Google');
expect(result.model).toBe('Pixel 8 Pro');
expect(result.os).toBe('Android');
expect(result.osVersion).toBe('14');
expect(result.device).toBe('mobile');
});
it('should detect OnePlus', () => {
const result = parseUserAgent(
'App/1.0 (Android 13; Model=OnePlus 11; Manufacturer=OnePlus)',
);
expect(result.brand).toBe('OnePlus');
expect(result.model).toBe('OnePlus 11');
expect(result.device).toBe('mobile');
expect(result.os).toBe('Android');
expect(result.osVersion).toBe('13');
});
it('should detect Huawei', () => {
const result = parseUserAgent(
'App/1.0 (Android 12; Model=P60 Pro; Manufacturer=Huawei)',
);
expect(result.brand).toBe('Huawei');
expect(result.model).toBe('P60 Pro');
expect(result.device).toBe('mobile');
expect(result.os).toBe('Android');
expect(result.osVersion).toBe('12');
});
});

View File

@@ -18,6 +18,10 @@ const IOS_MODEL_REGEX = /(iOS)\s*([0-9\.]+)/i;
const IPAD_OS_VERSION_REGEX = /iPadOS\s*([0-9_]+)/i;
const SINGLE_NAME_VERSION_REGEX = /^[^\/]+\/[\d.]+$/;
// App-style UA patterns (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
const APP_MODEL_REGEX = /Model=([^;)]+)/i;
const APP_MANUFACTURER_REGEX = /Manufacturer=([^;)]+)/i;
// Device detection regexes
const SAMSUNG_MOBILE_REGEX = /SM-[ABDEFGJMNRWZ][0-9]+/i;
const SAMSUNG_TABLET_REGEX = /SM-T[0-9]+/i;
@@ -30,6 +34,76 @@ const TABLET_REGEX = /tablet|ipad|xoom|sch-i800|kindle|silk|playbook/i;
const ANDROID_REGEX = /android/i;
const MOBILE_KEYWORD_REGEX = /mobile/i;
// Known phone model patterns from top brands (for device type detection)
// These patterns indicate mobile phones, not tablets
const KNOWN_PHONE_PATTERNS = [
// Xiaomi / Redmi / POCO
/redmi\s*(note|k|[0-9])/i,
/poco\s*[a-z0-9]/i,
/mi\s*([0-9]|note|mix|max)/i,
// Samsung Galaxy phones (not tablets)
/galaxy\s*(s|a|m|note)[0-9]/i,
/galaxy\s*z\s*(fold|flip)/i, // Foldables
// Huawei / Honor
/huawei\s*(p|mate|nova|y)[0-9]/i,
/honor\s*[0-9a-z]/i,
// OPPO
/oppo\s*(a|f|find|reno|k)\s*[a-z0-9]/i,
// Vivo
/vivo\s*(v|y|x|s|t|iqoo)[0-9]/i,
/iqoo\s*[0-9a-z]/i,
// OnePlus
/oneplus\s*[0-9]/i,
/one\s*plus\s*[0-9]/i,
// Google Pixel
/pixel\s*[0-9]/i,
// Realme
/realme\s*[0-9a-z]/i,
// Motorola
/moto\s*(g|e|x|z|edge|razr)/i,
/motorola\s*(edge|razr)/i,
// Nokia
/nokia\s*[0-9]/i,
// Sony Xperia
/xperia\s*[0-9a-z]/i,
// Nothing
/nothing\s*phone/i,
];
// Brand detection patterns with normalized names
// Order matters - more specific patterns should come first
const BRAND_PATTERNS: Array<{ pattern: RegExp; brand: string }> = [
{ pattern: /xiaomi/i, brand: 'Xiaomi' },
{ pattern: /redmi/i, brand: 'Xiaomi' },
{ pattern: /poco/i, brand: 'Xiaomi' },
{ pattern: /samsung/i, brand: 'Samsung' },
{ pattern: /galaxy/i, brand: 'Samsung' },
{ pattern: /huawei/i, brand: 'Huawei' },
{ pattern: /honor/i, brand: 'Honor' },
{ pattern: /oppo/i, brand: 'OPPO' },
{ pattern: /vivo/i, brand: 'Vivo' },
{ pattern: /iqoo/i, brand: 'Vivo' },
{ pattern: /oneplus|one\s*plus/i, brand: 'OnePlus' },
{ pattern: /google/i, brand: 'Google' },
{ pattern: /pixel/i, brand: 'Google' },
{ pattern: /realme/i, brand: 'Realme' },
{ pattern: /motorola/i, brand: 'Motorola' },
{ pattern: /moto\s/i, brand: 'Motorola' },
{ pattern: /nokia/i, brand: 'Nokia' },
{ pattern: /sony/i, brand: 'Sony' },
{ pattern: /xperia/i, brand: 'Sony' },
{ pattern: /nothing/i, brand: 'Nothing' },
// Be specific with Apple - don't match "AppleWebKit"
{ pattern: /\bapple\b(?!webkit)/i, brand: 'Apple' },
{ pattern: /\biphone\b/i, brand: 'Apple' },
{ pattern: /\bipad\b/i, brand: 'Apple' },
{ pattern: /lg[- ]/i, brand: 'LG' },
{ pattern: /zte/i, brand: 'ZTE' },
{ pattern: /lenovo/i, brand: 'Lenovo' },
{ pattern: /asus/i, brand: 'ASUS' },
{ pattern: /tcl/i, brand: 'TCL' },
];
// Cache for parsed results - stores up to 1000 unique user agents
const parseCache = new LRUCache<string, UAParser.IResult>({
ttl: 1000 * 60 * 5,
@@ -49,6 +123,37 @@ const isIphone = (ua: string) => {
: null;
};
// Extract app-style UA info (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
function extractAppStyleInfo(ua: string): {
model?: string;
manufacturer?: string;
} {
const modelMatch = ua.match(APP_MODEL_REGEX);
const manufacturerMatch = ua.match(APP_MANUFACTURER_REGEX);
return {
model: modelMatch?.[1]?.trim(),
manufacturer: manufacturerMatch?.[1]?.trim(),
};
}
// Detect brand from UA string or model name
function detectBrand(ua: string, model?: string): string | undefined {
const searchString = `${ua} ${model || ''}`;
for (const { pattern, brand } of BRAND_PATTERNS) {
if (pattern.test(searchString)) {
return brand;
}
}
return undefined;
}
// Check if a model name indicates a phone (not tablet)
function isKnownPhoneModel(model?: string): boolean {
if (!model) return false;
return KNOWN_PHONE_PATTERNS.some((pattern) => pattern.test(model));
}
const parse = (ua: string): UAParser.IResult => {
// Check cache first
const cached = parseCache.get(ua);
@@ -57,7 +162,7 @@ const parse = (ua: string): UAParser.IResult => {
}
const parser = new UAParser(ua);
const res = parser.getResult();
let res = parser.getResult();
// Some user agents are not detected correctly by ua-parser-js
// Doing some extra checks for ios
@@ -86,15 +191,45 @@ const parse = (ua: string): UAParser.IResult => {
if (res.device.model === 'iPad' && !res.os.version) {
const osVersion = ua.match(IPAD_OS_VERSION_REGEX);
if (osVersion) {
const result = {
res = {
...res,
os: {
...res.os,
version: osVersion[1]!.replace(/_/g, '.'),
},
};
parseCache.set(ua, result);
return result;
}
}
// Extract app-style UA info (e.g., "Model=Redmi Note 8 Pro; Manufacturer=Xiaomi")
// This handles UAs from mobile apps that include device info in a custom format
const appInfo = extractAppStyleInfo(ua);
if (appInfo.model || appInfo.manufacturer) {
const model = res.device.model || appInfo.model;
const vendor =
res.device.vendor || appInfo.manufacturer || detectBrand(ua, model);
res = {
...res,
device: {
...res.device,
model: model,
vendor: vendor,
},
};
}
// If we still don't have a vendor, try to detect brand from UA or model
if (!res.device.vendor && (res.device.model || res.os.name)) {
const detectedBrand = detectBrand(ua, res.device.model);
if (detectedBrand) {
res = {
...res,
device: {
...res.device,
vendor: detectedBrand,
},
};
}
}
@@ -116,6 +251,11 @@ export function parseUserAgent(
return parsedServerUa;
}
const model =
typeof overrides?.__model === 'string' && overrides?.__model
? overrides?.__model
: res.device.model;
return {
os:
typeof overrides?.__os === 'string' && overrides?.__os
@@ -137,15 +277,12 @@ export function parseUserAgent(
device:
typeof overrides?.__device === 'string' && overrides?.__device
? overrides?.__device
: res.device.type || getDevice(ua),
: res.device.type || getDevice(ua, model),
brand:
typeof overrides?.__brand === 'string' && overrides?.__brand
? overrides?.__brand
: res.device.vendor,
model:
typeof overrides?.__model === 'string' && overrides?.__model
? overrides?.__model
: res.device.model,
model,
isServer: false,
} as const;
}
@@ -166,7 +303,13 @@ function isServer(res: UAParser.IResult) {
);
}
export function getDevice(ua: string) {
export function getDevice(ua: string, model?: string) {
// Check for known phone models first (from top brands)
// This handles app-style UAs where the model is explicitly stated
if (isKnownPhoneModel(model) || isKnownPhoneModel(ua)) {
return 'mobile';
}
// Samsung mobile devices use SM-[A,G,N,etc]XXX pattern
const isSamsungMobile = SAMSUNG_MOBILE_REGEX.test(ua);
if (isSamsungMobile) {
@@ -197,13 +340,30 @@ export function getDevice(ua: string) {
const isAndroid = ANDROID_REGEX.test(ua);
const hasMobileKeyword = MOBILE_KEYWORD_REGEX.test(ua);
const tablet =
TABLET_REGEX.test(ua) ||
(isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile);
// Only consider it a tablet if it's explicitly a tablet device
// Don't default Android to tablet just because "mobile" keyword is missing
const tablet = TABLET_REGEX.test(ua);
if (tablet) {
return 'tablet';
}
// For Android without explicit mobile/tablet indicators and no known model,
// check if there's any brand/model info suggesting it's a phone
if (isAndroid && !hasMobileKeyword && !isSamsungMobile && !isLGMobile) {
// Extract model from app-style UA if present
const appInfo = extractAppStyleInfo(ua);
if (appInfo.model && isKnownPhoneModel(appInfo.model)) {
return 'mobile';
}
// If we have a brand but no clear device type, assume mobile for Android
const brand = detectBrand(ua, appInfo.model);
if (brand) {
return 'mobile';
}
// Default Android without clear indicators to mobile (more common than tablet)
return 'mobile';
}
return 'desktop';
}