api: improve user-agent parsing
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user