UNPKG

express-useragent

Version:

JS Library & ExpressJS user-agent middleware exposing

1,472 lines (1,364 loc) 41.7 kB
import type { IncomingHttpHeaders } from 'http'; /** * Represents a single brand entry from User-Agent Client Hints * @see https://wicg.github.io/ua-client-hints/ */ export interface ClientHintBrand { brand: string; version: string; } /** * Parsed User-Agent Client Hints data from HTTP headers * Maps to the NavigatorUAData interface exposed by browsers */ export interface ClientHints { brands: ClientHintBrand[]; mobile: boolean; platform: string; platformVersion: string; architecture: string; bitness: string; model: string; fullVersionList: ClientHintBrand[]; } const BOTS = [ '\\+https:\\/\\/developers.google.com\\/\\+\\/web\\/snippet\\/', 'ad\\smonitoring', 'adsbot', 'apex', 'applebot', 'archive.org_bot', 'baiduspider', 'bingbot', 'chromeheadless', 'cloudflare', 'cloudinary', 'crawler', 'curl', 'discordbot', 'duckduckbot', 'embedly', 'exabot', 'facebookexternalhit', 'facebot', 'flipboard', 'google', 'googlebot', 'gsa-crawler', 'gurujibot', 'guzzlehttp', 'heritrix', 'ia_archiver', 'insights', 'linkedinbot', 'ltx71', 'mediapartners', 'msnbot', 'odklbot', 'phantom\\.js', 'phantomjs', 'pingdom', 'pinterest', 'python', 'rtlnieuws', 'skypeuripreview', 'slackbot', 'slurp', 'spbot', 'telegrambot', 'test\\scertificate', 'testing', 'tiabot', 'tumblr ', 'twitterbot', 'vkshare', 'web\\sscraper', 'wget', 'yandexbot', 'whatsapp', 'orangebot', 'smtbot', 'qwantify', 'mj12bot', 'ahrefsbot', 'seznambot', 'panscient\\.com', 'duckduckgo-favicons-bot', 'uptimerobot', 'semrushbot', 'postman', 'dotbot', 'zoominfobot', 'ifttt', 'sogou', 'ru_bot', 'researchscan', 'nimbostratus-bot', 'slack-imgproxy', 'node-superagent', 'go-http-client', 'jersey', 'dataprovider.com', 'github-camo', 'dispatch', 'checkmarknetwork', 'screaming frog', 'whatweb', 'daum', 'netcraftsurveyagent', 'mojeekbot', 'surdotlybot', 'springbot', ] as const; const IS_BOT_REGEXP = new RegExp(`(${BOTS.join('|')})`, 'i'); const SILK_REGEXP = /silk/i; const SILK_ACCELERATED_REGEXP = /Silk-Accelerated=true/i; const SMART_TV_REGEXP = /smart-tv|smarttv|googletv|appletv|hbbtv|pov_tv|netcast.tv/i; const ANDROID_TABLET_REGEXP = /mobile/i; const MOBILE_REGEXP = /mobile|^ios-/i; const DALVIK_REGEXP = /dalvik/i; const IOS_SCALE_REGEXP = /scale/i; const OKHTTP_REGEXP = /okhttp/i; const WEBKIT_REGEXP = /applewebkit/i; const WECHAT_REGEXP = /micromessenger/i; export interface AgentDetails extends Record<string, unknown> { isYaBrowser: boolean; isAuthoritative: boolean; isMobile: boolean; isMobileNative: boolean; isTablet: boolean; isiPad: boolean; isiPod: boolean; isiPhone: boolean; isiPhoneNative: boolean; isAndroid: boolean; isAndroidNative: boolean; isBlackberry: boolean; isOpera: boolean; isIE: boolean; isEdge: boolean; isIECompatibilityMode: boolean; isSafari: boolean; isFirefox: boolean; isWebkit: boolean; isChrome: boolean; isKonqueror: boolean; isOmniWeb: boolean; isSeaMonkey: boolean; isFlock: boolean; isAmaya: boolean; isPhantomJS: boolean; isEpiphany: boolean; isDesktop: boolean; isWindows: boolean; isLinux: boolean; isLinux64: boolean; isMac: boolean; isChromeOS: boolean; isBada: boolean; isSamsung: boolean; isRaspberry: boolean; isBot: boolean; botName: string; isCurl: boolean; isAndroidTablet: boolean; isWinJs: boolean; isKindleFire: boolean; isSilk: boolean; isCaptive: boolean; isSmartTV: boolean; isUC: boolean; isFacebook: boolean; isAlamoFire: boolean; isElectron: boolean; silkAccelerated: boolean; browser: string; version: string | number; os: string; platform: string; geoIp: Record<string, string | string[]>; source: string; isWechat: boolean; isWindowsPhone: boolean; electronVersion: string; SilkAccelerated?: boolean; /** DuckDuckGo browser detected via Client Hints brand or WebKit UA pattern */ isDuckDuckGo: boolean; /** Parsed User-Agent Client Hints when available */ clientHints: ClientHints | null; } export type HeadersLike = Partial<Record<string, string | string[] | undefined>>; const DEFAULT_AGENT: AgentDetails = { isYaBrowser: false, isAuthoritative: true, isMobile: false, isMobileNative: false, isTablet: false, isiPad: false, isiPod: false, isiPhone: false, isiPhoneNative: false, isAndroid: false, isAndroidNative: false, isBlackberry: false, isOpera: false, isIE: false, isEdge: false, isIECompatibilityMode: false, isSafari: false, isFirefox: false, isWebkit: false, isChrome: false, isKonqueror: false, isOmniWeb: false, isSeaMonkey: false, isFlock: false, isAmaya: false, isPhantomJS: false, isEpiphany: false, isDesktop: false, isWindows: false, isLinux: false, isLinux64: false, isMac: false, isChromeOS: false, isBada: false, isSamsung: false, isRaspberry: false, isBot: false, botName: '', isCurl: false, isAndroidTablet: false, isWinJs: false, isKindleFire: false, isSilk: false, isCaptive: false, isSmartTV: false, isUC: false, isFacebook: false, isAlamoFire: false, isElectron: false, silkAccelerated: false, browser: 'unknown', version: 'unknown', os: 'unknown', platform: 'unknown', isWechat: false, isWindowsPhone: false, SilkAccelerated: false, geoIp: {}, source: '', electronVersion: '', isDuckDuckGo: false, clientHints: null, }; function createDefaultAgent(): AgentDetails { return { ...DEFAULT_AGENT, geoIp: {}, source: '', electronVersion: '', botName: '', clientHints: null, }; } interface ProductToken { name: string; version: string; } const isProductTokenChar = (charCode: number): boolean => (charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122) || charCode === 45 || charCode === 46 || charCode === 95; const isDigit = (charCode: number): boolean => charCode >= 48 && charCode <= 57; const readProductToken = (source: string, startIndex = 0): ProductToken | null => { const slashIndex = source.indexOf('/', startIndex); if (slashIndex <= startIndex) { return null; } for (let index = startIndex; index < slashIndex; index += 1) { if (!isProductTokenChar(source.charCodeAt(index))) { return null; } } let endIndex = slashIndex + 1; while (endIndex < source.length && isProductTokenChar(source.charCodeAt(endIndex))) { endIndex += 1; } if (endIndex === slashIndex + 1) { return null; } return { name: source.slice(startIndex, slashIndex), version: source.slice(slashIndex + 1, endIndex), }; }; const readVersionAfterProduct = (source: string, productName: string): string | null => { const token = readProductToken(source); return token?.name.toLowerCase() === productName.toLowerCase() ? token.version : null; }; const readVersionAfterKnownPrefix = ( source: string, prefixes: readonly string[], ): string | null => { const lowerSource = source.toLowerCase(); let bestStart = -1; let bestPrefix = ''; for (const prefix of prefixes) { const prefixStart = lowerSource.indexOf(prefix.toLowerCase()); if (prefixStart !== -1 && (bestStart === -1 || prefixStart < bestStart)) { bestStart = prefixStart; bestPrefix = prefix; } } if (bestStart === -1) { return null; } const versionStart = bestStart + bestPrefix.length; let versionEnd = versionStart; while (versionEnd < source.length && isProductTokenChar(source.charCodeAt(versionEnd))) { versionEnd += 1; } return versionEnd > versionStart ? source.slice(versionStart, versionEnd) : null; }; const readVersionAfterKnownProduct = ( source: string, productNames: readonly string[], ): string | null => readVersionAfterKnownPrefix( source, productNames.map((productName) => `${productName}/`), ); const readInternetExplorerVersion = (source: string): string | null => { const lowerSource = source.toLowerCase(); const msieIndex = lowerSource.indexOf('msie'); if (msieIndex !== -1) { let versionStart = msieIndex + 4; while (source[versionStart] === ' ') { versionStart += 1; } let versionEnd = versionStart; while (versionEnd < source.length) { const charCode = source.charCodeAt(versionEnd); if (!isDigit(charCode) && charCode !== 46) { break; } versionEnd += 1; } if (versionEnd > versionStart) { return source.slice(versionStart, versionEnd); } } const tridentIndex = lowerSource.indexOf('trident/'); if (tridentIndex === -1) { return null; } const rvIndex = lowerSource.indexOf('rv:', tridentIndex + 8); if (rvIndex === -1) { return null; } const versionStart = rvIndex + 3; let versionEnd = versionStart; while (versionEnd < source.length) { const charCode = source.charCodeAt(versionEnd); if (!isDigit(charCode) && charCode !== 46) { break; } versionEnd += 1; } return versionEnd > versionStart ? source.slice(versionStart, versionEnd) : null; }; const readTrailingProductToken = (source: string, requireClosingParen: boolean): string | null => { let endIndex = source.length; while (endIndex > 0 && source.charCodeAt(endIndex - 1) === 32) { endIndex -= 1; } if (source[endIndex - 1] === ')') { endIndex -= 1; } else if (requireClosingParen) { return null; } let startIndex = endIndex; while (startIndex > 0 && isProductTokenChar(source.charCodeAt(startIndex - 1))) { startIndex -= 1; } return startIndex < endIndex ? source.slice(startIndex, endIndex) : null; }; const readIOSDeviceOSMatch = (source: string, device: 'iPad' | 'iPhone'): string | null => { const deviceLower = device.toLowerCase(); let searchFrom = 0; while (searchFrom < source.length) { const openIndex = source.indexOf('(', searchFrom); if (openIndex === -1) { return null; } const closeIndex = source.indexOf(')', openIndex + 1); const segmentEnd = closeIndex === -1 ? source.length : closeIndex; const segment = source.slice(openIndex + 1, segmentEnd); const segmentLower = segment.toLowerCase(); if (segmentLower.startsWith(deviceLower)) { let osSearchFrom = 0; while (osSearchFrom < segmentLower.length) { const osIndex = segmentLower.indexOf('os ', osSearchFrom); if (osIndex === -1) { break; } let versionStart = osIndex + 3; while (segment[versionStart] === ' ') { versionStart += 1; } let majorEnd = versionStart; while (majorEnd < segment.length && isDigit(segment.charCodeAt(majorEnd))) { majorEnd += 1; } const separator = segment[majorEnd]; if (majorEnd > versionStart && (separator === '.' || separator === '_')) { let minorEnd = majorEnd + 1; while (minorEnd < segment.length && isDigit(segment.charCodeAt(minorEnd))) { minorEnd += 1; } if (minorEnd > majorEnd + 1) { return `(${segment.slice(0, minorEnd)}`.replace('_', '.'); } } osSearchFrom = osIndex + 3; } } searchFrom = segmentEnd + 1; } return null; }; /** WebKit DuckDuckGo pattern: " Ddg/X.Y.Z" at end of UA string */ const DUCKDUCKGO_WEBKIT_REGEXP = /\sDdg\/\d+(?:\.\d+)*$/; export class UserAgent { private readonly versions: Record<string, RegExp> = { Edge: /(?:edge|edga|edgios|edg)\/([A-Za-z0-9_.-]+)/i, Firefox: /(?:firefox|fxios)\/([A-Za-z0-9_.-]+)/i, IE: /msie\s(\d+(?:\.\d+)?)|trident\/\d+(?:\.\d+)?;[^)]*\brv:(\d+(?:\.\d+)?)/i, YaBrowser: /(?:yabrowser|yowser)\/([A-Za-z0-9_.-]+)/i, Chrome: /(?:chrome|crios)\/([A-Za-z0-9_.-]+)/i, Chromium: /chromium\/([A-Za-z0-9_.-]+)/i, Safari: /(version|safari)\/([A-Za-z0-9_.-]+)/i, Opera: /version\/([A-Za-z0-9_.-]+)|OPR\/([A-Za-z0-9_.-]+)/i, Amaya: /amaya\/([A-Za-z0-9_.-]+)/i, SeaMonkey: /seamonkey\/([A-Za-z0-9_.-]+)/i, OmniWeb: /omniweb\/v([A-Za-z0-9_.-]+)/i, Flock: /flock\/([A-Za-z0-9_.-]+)/i, Epiphany: /epiphany\/([A-Za-z0-9_.-]+)/i, WinJs: /msapphost\/([A-Za-z0-9_.-]+)/i, PhantomJS: /phantomjs\/([A-Za-z0-9_.-]+)/i, AlamoFire: /alamofire\/([A-Za-z0-9_.-]+)/i, UC: /ucbrowser\/([A-Za-z0-9_.]+)/i, Facebook: /FBAV\/([A-Za-z0-9_.]+)/i, WebKit: /applewebkit\/([A-Za-z0-9_.]+)/i, Wechat: /micromessenger\/([A-Za-z0-9_.]+)/i, Electron: /Electron\/([A-Za-z0-9_.]+)/i, DuckDuckGo: /\sDdg\/(\d+(?:\.\d+)*)$/i, }; private readonly browsers: Record<string, RegExp> = { YaBrowser: /yabrowser|yowser/i, Edge: /edge|edga|edgios|edg/i, Amaya: /amaya/i, Konqueror: /konqueror/i, Epiphany: /epiphany/i, SeaMonkey: /seamonkey/i, Flock: /flock/i, OmniWeb: /omniweb/i, Chromium: /chromium/i, Chrome: /chrome|crios/i, Safari: /safari/i, IE: /msie|trident/i, Opera: /opera|OPR\//i, PS3: /playstation 3/i, PSP: /playstation portable/i, Firefox: /firefox|fxios/i, WinJs: /msapphost/i, PhantomJS: /phantomjs/i, AlamoFire: /alamofire/i, UC: /UCBrowser/i, Facebook: /FBA[NV]/, DuckDuckGo: /\sDdg\/[\d.]+$/i, }; private readonly os: Record<string, RegExp> = { Windows11: /\bwindows(?:\s|_|)11(?:\.\d+)?/i, Windows10: /windows nt 10\.0/i, Windows81: /windows nt 6\.3/i, Windows8: /windows nt 6\.2/i, Windows7: /windows nt 6\.1/i, UnknownWindows: /windows nt 6\.\d+/i, WindowsVista: /windows nt 6\.0/i, Windows2003: /windows nt 5\.2/i, WindowsXP: /windows nt 5\.1/i, Windows2000: /windows nt 5\.0/i, WindowsPhone81: /windows phone 8\.1/i, WindowsPhone80: /windows phone 8\.0/i, OSXCheetah: /os x 10[._]0/i, OSXPuma: /os x 10[._]1(\D|$)/i, OSXJaguar: /os x 10[._]2/i, OSXPanther: /os x 10[._]3/i, OSXTiger: /os x 10[._]4/i, OSXLeopard: /os x 10[._]5/i, OSXSnowLeopard: /os x 10[._]6/i, OSXLion: /os x 10[._]7/i, OSXMountainLion: /os x 10[._]8/i, OSXMavericks: /os x 10[._]9/i, OSXYosemite: /os x 10[._]10/i, OSXElCapitan: /os x 10[._]11/i, MacOSSierra: /os x 10[._]12/i, MacOSHighSierra: /os x 10[._]13/i, MacOSMojave: /os x 10[._]14/i, MacOSCatalina: /os x 10[._]15/i, MacOSBigSur: /(mac os x 10[._]16(?:[._]\d+)?|mac os (?:x )?11[._]\d+)/i, MacOSMonterey: /mac os (?:x )?12[._]\d+/i, MacOSVentura: /mac os (?:x )?13[._]\d+/i, MacOSSonoma: /mac os (?:x )?14[._]\d+/i, MacOSSequoia: /mac os (?:x )?15[._]\d+/i, MacOSTahoe: /mac os (?:x )?26[._]\d+/i, Mac: /os x/i, Linux: /linux/i, Linux64: /linux x86_64/i, ChromeOS: /cros/i, Wii: /wii/i, PS3: /playstation 3/i, PSP: /playstation portable/i, iOS: /ios/i, Bada: /Bada\/(\d+)\.(\d+)/i, Curl: /curl\/(\d+)\.(\d+)\.(\d+)/i, Electron: /Electron\/(\d+)\.(\d+)\.(\d+)/i, }; private readonly platform: Record<string, RegExp> = { Windows: /windows nt/i, WindowsPhone: /windows phone/i, Mac: /macintosh/i, Linux: /linux/i, Wii: /wii/i, Playstation: /playstation/i, iPad: /ipad/i, iPod: /ipod/i, iPhone: /iphone/i, Android: /android/i, Blackberry: /blackberry/i, Samsung: /samsung/i, Curl: /curl/i, Electron: /Electron/i, iOS: /^ios-/i, }; public Agent: AgentDetails; constructor() { this.Agent = createDefaultAgent(); } public reset(): this { this.Agent = createDefaultAgent(); return this; } public testNginxGeoIP(headers: HeadersLike | IncomingHttpHeaders): this { Object.entries(headers ?? {}).forEach(([key, value]) => { if (/^GEOIP/i.test(key) && value !== undefined) { this.Agent.geoIp[key] = Array.isArray(value) ? value.join(',') : value; } }); return this; } /** Maximum header length to process (prevents DoS from oversized headers) */ private static readonly MAX_HEADER_LENGTH = 2048; /** Maximum number of brands to parse from a brand list */ private static readonly MAX_BRAND_COUNT = 20; /** * Parse User-Agent Client Hints from HTTP headers * @see https://wicg.github.io/ua-client-hints/ */ public parseClientHints(headers: HeadersLike | IncomingHttpHeaders): ClientHints | null { const resolveHeader = (value: string | string[] | undefined): string => { try { if (value === null || value === undefined) { return ''; } if (Array.isArray(value)) { const first = value[0]; if (typeof first !== 'string') { return ''; } return first.slice(0, UserAgent.MAX_HEADER_LENGTH); } if (typeof value !== 'string') { return ''; } return value.slice(0, UserAgent.MAX_HEADER_LENGTH); } catch { return ''; } }; // Validate headers input if (headers === null || headers === undefined || typeof headers !== 'object') { return null; } // Normalize header keys to lowercase for case-insensitive lookup const normalizedHeaders: Record<string, string> = {}; try { let headerCount = 0; const maxHeaders = 50; // Limit iterations over headers object for (const [key, value] of Object.entries(headers)) { if (++headerCount > maxHeaders) break; if (typeof key !== 'string') continue; normalizedHeaders[key.toLowerCase()] = resolveHeader(value); } } catch { return null; } const secChUa = normalizedHeaders['sec-ch-ua']; // Return null if no client hints are present if (!secChUa) { return null; } const parseBrandList = (header: string): ClientHintBrand[] => { try { if (!header || typeof header !== 'string') return []; const brands: ClientHintBrand[] = []; // Match patterns like: "Brand";v="version" or "Brand"; v="version" const brandRegex = /"([^"]{1,128})";\s*v="([^"]{1,64})"/g; let match; let iterations = 0; while ((match = brandRegex.exec(header)) !== null) { if (++iterations > UserAgent.MAX_BRAND_COUNT) break; brands.push({ brand: match[1], version: match[2] }); } return brands; } catch { return []; } }; const parseMobile = (header: string): boolean => { try { // ?1 = true, ?0 or empty = false if (typeof header !== 'string') return false; return header === '?1'; } catch { return false; } }; const parseQuotedString = (header: string): string => { try { if (typeof header !== 'string') return ''; // Limit input length before regex const truncated = header.slice(0, 256); // Remove surrounding quotes if present const match = /^"([^"]*)"$/.exec(truncated); return match ? match[1] : truncated; } catch { return ''; } }; try { const clientHints: ClientHints = { brands: parseBrandList(secChUa), mobile: parseMobile(normalizedHeaders['sec-ch-ua-mobile'] ?? ''), platform: parseQuotedString(normalizedHeaders['sec-ch-ua-platform'] ?? ''), platformVersion: parseQuotedString(normalizedHeaders['sec-ch-ua-platform-version'] ?? ''), architecture: parseQuotedString(normalizedHeaders['sec-ch-ua-arch'] ?? ''), bitness: parseQuotedString(normalizedHeaders['sec-ch-ua-bitness'] ?? ''), model: parseQuotedString(normalizedHeaders['sec-ch-ua-model'] ?? ''), fullVersionList: parseBrandList(normalizedHeaders['sec-ch-ua-full-version-list'] ?? ''), }; this.Agent.clientHints = clientHints; return clientHints; } catch { return null; } } /** * Test for DuckDuckGo browser using both Client Hints and UA string patterns * - Chromium platforms (Android, Windows): Sec-CH-UA brand "DuckDuckGo" * - WebKit platforms (iOS, macOS): UA string ends with " Ddg/X.Y.Z" */ public testDuckDuckGo(): void { // Check client hints brands first (Chromium-based DDG) if (this.Agent.clientHints?.brands) { const hasDdgBrand = this.Agent.clientHints.brands.some( (brand) => brand.brand === 'DuckDuckGo', ); if (hasDdgBrand) { this.Agent.isDuckDuckGo = true; this.Agent.browser = 'DuckDuckGo'; this.Agent.version = this.getDuckDuckGoVersion() ?? 'unknown'; return; } } // Check full version list as well if (this.Agent.clientHints?.fullVersionList) { const hasDdgBrand = this.Agent.clientHints.fullVersionList.some( (brand) => brand.brand === 'DuckDuckGo', ); if (hasDdgBrand) { this.Agent.isDuckDuckGo = true; this.Agent.browser = 'DuckDuckGo'; this.Agent.version = this.getDuckDuckGoVersion() ?? 'unknown'; return; } } // Fallback: check WebKit UA string pattern (iOS/macOS DDG) if (DUCKDUCKGO_WEBKIT_REGEXP.test(this.Agent.source)) { this.Agent.isDuckDuckGo = true; this.Agent.browser = 'DuckDuckGo'; this.Agent.version = this.getDuckDuckGoVersion() ?? 'unknown'; } } public testBot(): void { const source = this.Agent.source.toLowerCase(); const match = IS_BOT_REGEXP.exec(source); if (match) { const botIdentifier = match[1]; // Handle false positives - TikTok WebView contains "googleplay" but isn't a bot if ( botIdentifier === 'google' && (source.includes('tiktok') || source.includes('trill') || source.includes('bytedance')) ) { this.Agent.isBot = false; this.Agent.botName = ''; return; } // For all bots, return boolean true and store bot name (fixes issues #168, #138) this.Agent.isBot = true; this.Agent.botName = botIdentifier; } else if (!this.Agent.isAuthoritative) { this.Agent.isBot = /bot/i.test(this.Agent.source); this.Agent.botName = this.Agent.isBot ? 'bot' : ''; } else { this.Agent.isBot = false; this.Agent.botName = ''; } } public testSmartTV(): void { this.Agent.isSmartTV = SMART_TV_REGEXP.test(this.Agent.source.toLowerCase()); } public testMobile(): void { if (this.Agent.isWindows || this.Agent.isLinux || this.Agent.isMac || this.Agent.isChromeOS) { this.Agent.isDesktop = true; } else if (this.Agent.isAndroid || this.Agent.isSamsung) { this.Agent.isMobile = true; } if ( this.Agent.isiPad || this.Agent.isiPod || this.Agent.isiPhone || this.Agent.isBada || this.Agent.isBlackberry || this.Agent.isAndroid || this.Agent.isWindowsPhone ) { this.Agent.isMobile = true; this.Agent.isDesktop = false; } if (MOBILE_REGEXP.test(this.Agent.source)) { this.Agent.isMobile = true; this.Agent.isDesktop = false; } if (DALVIK_REGEXP.test(this.Agent.source)) { this.Agent.isAndroidNative = true; this.Agent.isMobileNative = true; } if (OKHTTP_REGEXP.test(this.Agent.source)) { this.Agent.isAndroidNative = true; this.Agent.isMobileNative = true; this.Agent.isMobile = true; this.Agent.isAndroid = true; this.Agent.isDesktop = false; } if (IOS_SCALE_REGEXP.test(this.Agent.source)) { this.Agent.isiPhoneNative = true; this.Agent.isMobileNative = true; } } public testAndroidTablet(): void { if (this.Agent.isAndroid && !ANDROID_TABLET_REGEXP.test(this.Agent.source)) { this.Agent.isAndroidTablet = true; } } public testTablet(): void { if (this.Agent.isiPad || this.Agent.isAndroidTablet || this.Agent.isKindleFire) { this.Agent.isTablet = true; } if (/tablet/i.test(this.Agent.source)) { this.Agent.isTablet = true; } } public testCompatibilityMode(): void { if (!this.Agent.isIE) { return; } const tridentMatch = /Trident\/(\d)\.0/i.exec(this.Agent.source); if (!tridentMatch) { return; } const tridentVersion = parseInt(tridentMatch[1], 10); const version = parseInt(String(this.Agent.version), 10); if (version === 7 && tridentVersion === 7) { this.Agent.isIECompatibilityMode = true; this.Agent.version = '11.0'; } if (version === 7 && tridentVersion === 6) { this.Agent.isIECompatibilityMode = true; this.Agent.version = '10.0'; } if (version === 7 && tridentVersion === 5) { this.Agent.isIECompatibilityMode = true; this.Agent.version = '9.0'; } if (version === 7 && tridentVersion === 4) { this.Agent.isIECompatibilityMode = true; this.Agent.version = '8.0'; } } public testSilk(): string | false { if (SILK_REGEXP.test(this.Agent.source)) { this.Agent.isSilk = true; } if (SILK_ACCELERATED_REGEXP.test(this.Agent.source)) { this.Agent.silkAccelerated = true; this.Agent.SilkAccelerated = true; } return this.Agent.isSilk ? 'Silk' : false; } public testKindleFire(): string | false { const { source } = this.Agent; if (/KFOT/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire'; } if (/KFTT/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HD'; } if (/KFJWI/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HD 8.9'; } if (/KFJWA/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HD 8.9 4G'; } if (/KFSOWI/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HD 7'; } if (/KFTHWI/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HDX 7'; } if (/KFTHWA/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HDX 7 4G'; } if (/KFAPWI/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HDX 8.9'; } if (/KFAPWA/gi.test(source)) { this.Agent.isKindleFire = true; return 'Kindle Fire HDX 8.9 4G'; } return false; } public testCaptiveNetwork(): string | false { if (/CaptiveNetwork/gi.test(this.Agent.source)) { this.Agent.isCaptive = true; this.Agent.isMac = true; this.Agent.platform = 'Apple Mac'; return 'CaptiveNetwork'; } return false; } public testWebkit(): void { if (this.Agent.browser === 'unknown' && WEBKIT_REGEXP.test(this.Agent.source)) { this.Agent.browser = 'Apple WebKit'; this.Agent.isWebkit = true; } } public testWechat(): void { if (WECHAT_REGEXP.test(this.Agent.source)) { this.Agent.isWechat = true; this.Agent.version = this.getWechatVersion(this.Agent.source); } } public parse(source: string): AgentDetails { return new UserAgent().hydrate(source).Agent; } /** * Hydrate agent from UA string and HTTP headers (including Client Hints) * This method should be preferred when headers are available as it enables * detection of browsers that use Client Hints (e.g., DuckDuckGo on Chromium) */ public hydrateFromHeaders(source: string, headers: HeadersLike | IncomingHttpHeaders): this { this.hydrate(source); this.parseClientHints(headers); this.testDuckDuckGo(); return this; } public hydrate(source: string): this { this.Agent = createDefaultAgent(); this.Agent.source = source.trim(); this.Agent.os = this.getOS(this.Agent.source); this.Agent.platform = this.getPlatform(this.Agent.source); this.Agent.browser = this.getBrowser(this.Agent.source); this.Agent.version = this.getBrowserVersion(this.Agent.source); this.Agent.electronVersion = this.getElectronVersion(this.Agent.source); this.testBot(); this.testSmartTV(); this.testMobile(); this.testAndroidTablet(); this.testTablet(); this.testCompatibilityMode(); this.testSilk(); this.testKindleFire(); this.testCaptiveNetwork(); this.testWebkit(); this.testWechat(); return this; } private getBrowser(string: string): string { const agent = this.Agent; if (this.browsers.YaBrowser.test(string)) { agent.isYaBrowser = true; return 'YaBrowser'; } if (this.browsers.AlamoFire.test(string)) { agent.isAlamoFire = true; return 'AlamoFire'; } if (this.browsers.Edge.test(string)) { agent.isEdge = true; return 'Edge'; } if (this.browsers.PhantomJS.test(string)) { agent.isPhantomJS = true; return 'PhantomJS'; } if (this.browsers.Konqueror.test(string)) { agent.isKonqueror = true; return 'Konqueror'; } if (this.browsers.Amaya.test(string)) { agent.isAmaya = true; return 'Amaya'; } if (this.browsers.Epiphany.test(string)) { agent.isEpiphany = true; return 'Epiphany'; } if (this.browsers.SeaMonkey.test(string)) { agent.isSeaMonkey = true; return 'SeaMonkey'; } if (this.browsers.Flock.test(string)) { agent.isFlock = true; return 'Flock'; } if (this.browsers.OmniWeb.test(string)) { agent.isOmniWeb = true; return 'OmniWeb'; } if (this.browsers.Opera.test(string)) { agent.isOpera = true; return 'Opera'; } if (this.browsers.Chromium.test(string)) { agent.isChrome = true; return 'Chromium'; } if (this.browsers.Facebook.test(string)) { agent.isFacebook = true; return 'Facebook'; } if (this.browsers.Chrome.test(string)) { agent.isChrome = true; return 'Chrome'; } if (this.browsers.WinJs.test(string)) { agent.isWinJs = true; return 'WinJs'; } if (this.browsers.IE.test(string)) { agent.isIE = true; return 'IE'; } if (this.browsers.Firefox.test(string)) { agent.isFirefox = true; return 'Firefox'; } // Android Browser: stock AOSP browser - has Android + Version/ + Mobile Safari/ but no Chrome or other browser tokens (Bug #80) if ( /android/i.test(string) && /version\//i.test(string) && /mobile safari\//i.test(string) && !/chrome/i.test(string) && !/silk/i.test(string) ) { agent.isAndroid = true; return 'Android Browser'; } if (this.browsers.Safari.test(string)) { agent.isSafari = true; return 'Safari'; } if (this.browsers.PS3.test(string)) { return 'ps3'; } if (this.browsers.PSP.test(string)) { return 'psp'; } if (this.browsers.UC.test(string)) { agent.isUC = true; return 'UCBrowser'; } if (string.includes('Dalvik')) { return 'unknown'; } if (!string.startsWith('Mozilla')) { const guess = readProductToken(string); if (guess) { agent.isAuthoritative = false; return guess.name; } } return 'unknown'; } private getBrowserVersion(string: string): string { const agent = this.Agent; const browser = agent.browser; switch (browser) { case 'Edge': return readVersionAfterKnownProduct(string, ['edge', 'edga', 'edgios', 'edg']) ?? 'unknown'; case 'PhantomJS': return readVersionAfterKnownProduct(string, ['phantomjs']) ?? 'unknown'; case 'YaBrowser': return readVersionAfterKnownProduct(string, ['yabrowser', 'yowser']) ?? 'unknown'; case 'Chrome': return readVersionAfterKnownProduct(string, ['chrome', 'crios']) ?? 'unknown'; case 'Chromium': return readVersionAfterKnownProduct(string, ['chromium']) ?? 'unknown'; case 'Safari': return readVersionAfterKnownProduct(string, ['version', 'safari']) ?? 'unknown'; case 'Opera': return readVersionAfterKnownProduct(string, ['version', 'OPR']) ?? 'unknown'; case 'Firefox': return readVersionAfterKnownProduct(string, ['firefox', 'fxios']) ?? 'unknown'; case 'WinJs': return readVersionAfterKnownProduct(string, ['msapphost']) ?? 'unknown'; case 'IE': return readInternetExplorerVersion(string) ?? 'unknown'; case 'ps3': return readTrailingProductToken(string, true) ?? 'unknown'; case 'psp': return readTrailingProductToken(string, false) ?? 'unknown'; case 'Amaya': return readVersionAfterKnownProduct(string, ['amaya']) ?? 'unknown'; case 'Epiphany': return readVersionAfterKnownProduct(string, ['epiphany']) ?? 'unknown'; case 'SeaMonkey': return readVersionAfterKnownProduct(string, ['seamonkey']) ?? 'unknown'; case 'Flock': return readVersionAfterKnownProduct(string, ['flock']) ?? 'unknown'; case 'OmniWeb': return readVersionAfterKnownPrefix(string, ['omniweb/v']) ?? 'unknown'; case 'UCBrowser': return readVersionAfterKnownProduct(string, ['ucbrowser']) ?? 'unknown'; case 'Facebook': return readVersionAfterKnownProduct(string, ['FBAV']) ?? 'unknown'; case 'Android Browser': // Android Browser reports version via Version/X.X token (Bug #80) return readVersionAfterKnownProduct(string, ['version', 'safari']) ?? 'unknown'; case 'DuckDuckGo': return this.getDuckDuckGoVersion() ?? 'unknown'; default: if (browser !== 'unknown') { return readVersionAfterProduct(string, browser) ?? 'unknown'; } else { this.testWebkit(); if (this.Agent.isWebkit) { return readVersionAfterKnownProduct(string, ['applewebkit']) ?? 'unknown'; } } } return 'unknown'; } private getWechatVersion(string: string): string { const match = string.match(this.versions.Wechat); return match ? match[1] : 'unknown'; } private getDuckDuckGoVersion(): string | null { // Try client hints first const hints = this.Agent.clientHints; if (hints) { // Check fullVersionList first for more precise version const fullBrand = hints.fullVersionList.find((b) => b.brand === 'DuckDuckGo'); if (fullBrand) { return fullBrand.version; } // Fall back to brands const brand = hints.brands.find((b) => b.brand === 'DuckDuckGo'); if (brand) { return brand.version; } } // Fall back to UA string pattern const match = this.Agent.source.match(this.versions.DuckDuckGo); return match ? match[1] : null; } private getElectronVersion(string: string): string { const match = string.match(this.versions.Electron); if (match) { this.Agent.isElectron = true; return match[1]; } return ''; } private getOS(string: string): string { if (this.os.WindowsVista.test(string)) { this.Agent.isWindows = true; return 'Windows Vista'; } if (this.os.Windows7.test(string)) { this.Agent.isWindows = true; return 'Windows 7'; } if (this.os.Windows8.test(string)) { this.Agent.isWindows = true; return 'Windows 8'; } if (this.os.Windows81.test(string)) { this.Agent.isWindows = true; return 'Windows 8.1'; } if (this.os.Windows11.test(string)) { this.Agent.isWindows = true; return 'Windows 11'; } if (this.os.Windows10.test(string)) { this.Agent.isWindows = true; return 'Windows 10.0'; } if (this.os.Windows2003.test(string)) { this.Agent.isWindows = true; return 'Windows 2003'; } if (this.os.WindowsXP.test(string)) { this.Agent.isWindows = true; return 'Windows XP'; } if (this.os.Windows2000.test(string)) { this.Agent.isWindows = true; return 'Windows 2000'; } if (this.os.WindowsPhone81.test(string)) { this.Agent.isWindowsPhone = true; return 'Windows Phone 8.1'; } if (this.os.WindowsPhone80.test(string)) { this.Agent.isWindowsPhone = true; return 'Windows Phone 8.0'; } if (this.os.Linux64.test(string)) { this.Agent.isLinux = true; this.Agent.isLinux64 = true; return 'Linux 64'; } if (this.os.Linux.test(string)) { this.Agent.isLinux = true; return 'Linux'; } if (this.os.ChromeOS.test(string)) { this.Agent.isChromeOS = true; return 'Chrome OS'; } if (this.os.Wii.test(string)) { return 'Wii'; } if (this.os.PS3.test(string)) { return 'Playstation'; } if (this.os.PSP.test(string)) { return 'Playstation'; } if (this.os.OSXCheetah.test(string)) { this.Agent.isMac = true; return 'OS X Cheetah'; } if (this.os.OSXPuma.test(string)) { this.Agent.isMac = true; return 'OS X Puma'; } if (this.os.OSXJaguar.test(string)) { this.Agent.isMac = true; return 'OS X Jaguar'; } if (this.os.OSXPanther.test(string)) { this.Agent.isMac = true; return 'OS X Panther'; } if (this.os.OSXTiger.test(string)) { this.Agent.isMac = true; return 'OS X Tiger'; } if (this.os.OSXLeopard.test(string)) { this.Agent.isMac = true; return 'OS X Leopard'; } if (this.os.OSXSnowLeopard.test(string)) { this.Agent.isMac = true; return 'OS X Snow Leopard'; } if (this.os.OSXLion.test(string)) { this.Agent.isMac = true; return 'OS X Lion'; } if (this.os.OSXMountainLion.test(string)) { this.Agent.isMac = true; return 'OS X Mountain Lion'; } if (this.os.OSXMavericks.test(string)) { this.Agent.isMac = true; return 'OS X Mavericks'; } if (this.os.OSXYosemite.test(string)) { this.Agent.isMac = true; return 'OS X Yosemite'; } if (this.os.OSXElCapitan.test(string)) { this.Agent.isMac = true; return 'OS X El Capitan'; } if (this.os.MacOSSierra.test(string)) { this.Agent.isMac = true; return 'macOS Sierra'; } if (this.os.MacOSHighSierra.test(string)) { this.Agent.isMac = true; return 'macOS High Sierra'; } if (this.os.MacOSMojave.test(string)) { this.Agent.isMac = true; return 'macOS Mojave'; } if (this.os.MacOSCatalina.test(string)) { this.Agent.isMac = true; return 'macOS Catalina'; } if (this.os.MacOSBigSur.test(string)) { this.Agent.isMac = true; return 'macOS Big Sur'; } if (this.os.MacOSMonterey.test(string)) { this.Agent.isMac = true; return 'macOS Monterey'; } if (this.os.MacOSVentura.test(string)) { this.Agent.isMac = true; return 'macOS Ventura'; } if (this.os.MacOSSonoma.test(string)) { this.Agent.isMac = true; return 'macOS Sonoma'; } if (this.os.MacOSSequoia.test(string)) { this.Agent.isMac = true; return 'macOS Sequoia'; } if (this.os.MacOSTahoe.test(string)) { this.Agent.isMac = true; return 'macOS Tahoe'; } if (this.os.Mac.test(string)) { this.Agent.isMac = true; return 'OS X'; } const iPadMatch = readIOSDeviceOSMatch(string, 'iPad'); if (iPadMatch) { this.Agent.isiPad = true; return iPadMatch; } const iPhoneMatch = readIOSDeviceOSMatch(string, 'iPhone'); if (iPhoneMatch) { this.Agent.isiPhone = true; return iPhoneMatch; } if (this.os.Bada.test(string)) { this.Agent.isBada = true; return 'Bada'; } if (this.os.Curl.test(string)) { this.Agent.isCurl = true; return 'Curl'; } if (this.os.iOS.test(string)) { this.Agent.isiPhone = true; return 'iOS'; } if (this.os.Electron.test(string)) { this.Agent.isElectron = true; return 'Electron'; } return 'unknown'; } private getPlatform(string: string): string { if (this.platform.Windows.test(string)) { return 'Microsoft Windows'; } if (this.platform.WindowsPhone.test(string)) { this.Agent.isWindowsPhone = true; return 'Microsoft Windows Phone'; } if (this.platform.Mac.test(string)) { return 'Apple Mac'; } if (this.platform.Curl.test(string)) { return 'Curl'; } if (this.platform.Electron.test(string)) { this.Agent.isElectron = true; return 'Electron'; } if (this.platform.Android.test(string)) { this.Agent.isAndroid = true; // Also detect Samsung devices within Android platform (Bug #104) if (this.platform.Samsung.test(string)) { this.Agent.isSamsung = true; } return 'Android'; } if (this.platform.Blackberry.test(string)) { this.Agent.isBlackberry = true; return 'Blackberry'; } if (this.platform.Linux.test(string)) { return 'Linux'; } if (this.platform.Wii.test(string)) { return 'Wii'; } if (this.platform.Playstation.test(string)) { return 'Playstation'; } if (this.platform.iPad.test(string)) { this.Agent.isiPad = true; return 'iPad'; } if (this.platform.iPod.test(string)) { this.Agent.isiPod = true; return 'iPod'; } if (this.platform.iPhone.test(string)) { this.Agent.isiPhone = true; return 'iPhone'; } if (this.platform.Samsung.test(string)) { this.Agent.isSamsung = true; return 'Samsung'; } if (this.platform.iOS.test(string)) { return 'Apple iOS'; } return 'unknown'; } }