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

@@ -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';
}