diff --git a/index.d.ts b/index.d.ts index 344a09c..4489af8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -7,20 +7,36 @@ export as namespace Bowser; declare namespace Bowser { /** - * Creates a Parser instance - * @param {string} UA - User agent string - * @param {boolean} skipParsing + * User-Agent Client Hints data structure + * @see https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData */ + interface ClientHints { + brands?: Array<{ brand: string; version: string }>; + mobile?: boolean; + platform?: string; + platformVersion?: string; + architecture?: string; + model?: string; + wow64?: boolean; + } + /** + * Creates a Parser instance + * @param {string} UA - User agent string + * @param {boolean | ClientHints} skipParsingOrHints - Either skip parsing flag or Client Hints + * @param {ClientHints} clientHints - User-Agent Client Hints data + */ function getParser(UA: string, skipParsing?: boolean): Parser.Parser; + function getParser(UA: string, clientHints?: ClientHints): Parser.Parser; + function getParser(UA: string, skipParsing?: boolean, clientHints?: ClientHints): Parser.Parser; /** * Creates a Parser instance and runs Parser.getResult immediately * @param UA - User agent string + * @param clientHints - User-Agent Client Hints data * @returns {Parser.ParsedResult} */ - - function parse(UA: string): Parser.ParsedResult; + function parse(UA: string, clientHints?: ClientHints): Parser.ParsedResult; /** * Constants exposed via bowser getters @@ -33,6 +49,28 @@ declare namespace Bowser { namespace Parser { interface Parser { constructor(UA: string, skipParsing?: boolean): Parser.Parser; + constructor(UA: string, clientHints?: ClientHints): Parser.Parser; + constructor(UA: string, skipParsing?: boolean, clientHints?: ClientHints): Parser.Parser; + + /** + * Get Client Hints data + * @return {ClientHints|null} + */ + getHints(): ClientHints | null; + + /** + * Check if a brand exists in Client Hints brands array + * @param {string} brandName The brand name to check for + * @return {boolean} + */ + hasBrand(brandName: string): boolean; + + /** + * Get brand version from Client Hints + * @param {string} brandName The brand name to get version for + * @return {string|undefined} + */ + getBrandVersion(brandName: string): string | undefined; /** * Check if the version is equals the browser version diff --git a/src/bowser.js b/src/bowser.js index 893a9eb..fd19706 100644 --- a/src/bowser.js +++ b/src/bowser.js @@ -28,33 +28,49 @@ class Bowser { * Creates a {@link Parser} instance * * @param {String} UA UserAgent string - * @param {Boolean} [skipParsing=false] Will make the Parser postpone parsing until you ask it - * explicitly. Same as `skipParsing` for {@link Parser}. + * @param {Boolean|Object} [skipParsingOrHints=false] Either a boolean to skip parsing, + * or a ClientHints object (navigator.userAgentData) + * @param {Object} [clientHints] User-Agent Client Hints data (navigator.userAgentData) * @returns {Parser} * @throws {Error} when UA is not a String * * @example * const parser = Bowser.getParser(window.navigator.userAgent); * const result = parser.getResult(); + * + * @example + * // With User-Agent Client Hints + * const parser = Bowser.getParser( + * window.navigator.userAgent, + * window.navigator.userAgentData + * ); */ - static getParser(UA, skipParsing = false) { + static getParser(UA, skipParsingOrHints = false, clientHints = null) { if (typeof UA !== 'string') { throw new Error('UserAgent should be a string'); } - return new Parser(UA, skipParsing); + return new Parser(UA, skipParsingOrHints, clientHints); } /** * Creates a {@link Parser} instance and runs {@link Parser.getResult} immediately * - * @param UA + * @param {String} UA UserAgent string + * @param {Object} [clientHints] User-Agent Client Hints data (navigator.userAgentData) * @return {ParsedResult} * * @example * const result = Bowser.parse(window.navigator.userAgent); + * + * @example + * // With User-Agent Client Hints + * const result = Bowser.parse( + * window.navigator.userAgent, + * window.navigator.userAgentData + * ); */ - static parse(UA) { - return (new Parser(UA)).getResult(); + static parse(UA, clientHints = null) { + return (new Parser(UA, clientHints)).getResult(); } static get BROWSER_MAP() { diff --git a/src/constants.js b/src/constants.js index 18ef699..2181225 100644 --- a/src/constants.js +++ b/src/constants.js @@ -14,6 +14,7 @@ export const BROWSER_ALIASES_MAP = { Chromium: 'chromium', Diffbot: 'diffbot', DuckDuckBot: 'duckduckbot', + DuckDuckGo: 'duckduckgo', Electron: 'electron', Epiphany: 'epiphany', FacebookExternalHit: 'facebookexternalhit', @@ -82,6 +83,7 @@ export const BROWSER_MAP = { chromium: 'Chromium', diffbot: 'Diffbot', duckduckbot: 'DuckDuckBot', + duckduckgo: 'DuckDuckGo', edge: 'Microsoft Edge', electron: 'Electron', epiphany: 'Epiphany', diff --git a/src/parser-browsers.js b/src/parser-browsers.js index ef24693..fba9250 100644 --- a/src/parser-browsers.js +++ b/src/parser-browsers.js @@ -987,6 +987,39 @@ const browsersList = [ return browser; }, }, + /* DuckDuckGo Browser */ + { + test(parser) { + // Chromium platforms (Android, Windows): check Client Hints brands first + if (parser.hasBrand('DuckDuckGo')) { + return true; + } + // WebKit platforms (iOS, macOS): check UA string for Ddg/version suffix + return parser.test(/\sDdg\/[\d.]+$/i); + }, + describe(ua, parser) { + const browser = { + name: 'DuckDuckGo', + }; + + // Try Client Hints brand version first + if (parser) { + const hintsVersion = parser.getBrandVersion('DuckDuckGo'); + if (hintsVersion) { + browser.version = hintsVersion; + return browser; + } + } + + // Fall back to WebKit UA pattern + const uaVersion = Utils.getFirstMatch(/\sDdg\/([\d.]+)$/i, ua); + if (uaVersion) { + browser.version = uaVersion; + } + + return browser; + }, + }, { test: [/chromium/i], describe(ua) { diff --git a/src/parser.js b/src/parser.js index db10657..0f51189 100644 --- a/src/parser.js +++ b/src/parser.js @@ -4,6 +4,17 @@ import platformParsersList from './parser-platforms.js'; import enginesParsersList from './parser-engines.js'; import Utils from './utils.js'; +/** + * @typedef {Object} ClientHints + * @property {Array<{brand: string, version: string}>} [brands] Array of brand objects + * @property {boolean} [mobile] Whether the device is mobile + * @property {string} [platform] Platform name (e.g., "Windows", "macOS") + * @property {string} [platformVersion] Platform version + * @property {string} [architecture] CPU architecture + * @property {string} [model] Device model + * @property {boolean} [wow64] Whether running under WoW64 + */ + /** * The main class that arranges the whole parsing process. */ @@ -12,21 +23,32 @@ class Parser { * Create instance of Parser * * @param {String} UA User-Agent string - * @param {Boolean} [skipParsing=false] parser can skip parsing in purpose of performance - * improvements if you need to make a more particular parsing - * like {@link Parser#parseBrowser} or {@link Parser#parsePlatform} + * @param {Boolean|ClientHints} [skipParsingOrHints=false] Either a boolean to skip parsing, + * or a ClientHints object containing User-Agent Client Hints data + * @param {ClientHints} [clientHints] User-Agent Client Hints data (navigator.userAgentData) * * @throw {Error} in case of empty UA String * * @constructor */ - constructor(UA, skipParsing = false) { + constructor(UA, skipParsingOrHints = false, clientHints = null) { if (UA === void (0) || UA === null || UA === '') { throw new Error("UserAgent parameter can't be empty"); } this._ua = UA; + // Handle overloaded constructor: (UA, clientHints) or (UA, skipParsing, clientHints) + let skipParsing = false; + if (typeof skipParsingOrHints === 'boolean') { + skipParsing = skipParsingOrHints; + this._hints = clientHints; + } else if (skipParsingOrHints != null && typeof skipParsingOrHints === 'object') { + this._hints = skipParsingOrHints; + } else { + this._hints = null; + } + /** * @typedef ParsedResult * @property {Object} browser @@ -56,6 +78,51 @@ class Parser { } } + /** + * Get Client Hints data + * @return {ClientHints|null} + * + * @public + */ + getHints() { + return this._hints; + } + + /** + * Check if a brand exists in Client Hints brands array + * @param {string} brandName The brand name to check for + * @return {boolean} + * + * @public + */ + hasBrand(brandName) { + if (!this._hints || !Array.isArray(this._hints.brands)) { + return false; + } + const brandLower = brandName.toLowerCase(); + return this._hints.brands.some( + b => b.brand && b.brand.toLowerCase() === brandLower, + ); + } + + /** + * Get brand version from Client Hints + * @param {string} brandName The brand name to get version for + * @return {string|undefined} + * + * @public + */ + getBrandVersion(brandName) { + if (!this._hints || !Array.isArray(this._hints.brands)) { + return undefined; + } + const brandLower = brandName.toLowerCase(); + const brand = this._hints.brands.find( + b => b.brand && b.brand.toLowerCase() === brandLower, + ); + return brand ? brand.version : undefined; + } + /** * Get UserAgent string of current Parser instance * @return {String} User-Agent String of the current object @@ -95,7 +162,7 @@ class Parser { }); if (browserDescriptor) { - this.parsedResult.browser = browserDescriptor.describe(this.getUA()); + this.parsedResult.browser = browserDescriptor.describe(this.getUA(), this); } return this.parsedResult.browser; diff --git a/test/acceptance/useragentstrings.yml b/test/acceptance/useragentstrings.yml index 1303411..517e413 100644 --- a/test/acceptance/useragentstrings.yml +++ b/test/acceptance/useragentstrings.yml @@ -2927,6 +2927,55 @@ type: "bot" vendor: "DuckDuckGo" engine: {} + DuckDuckGo: + - + ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Ddg/7.123.0" + spec: + browser: + name: "DuckDuckGo" + version: "7.123.0" + os: + name: "iOS" + version: "18.2" + platform: + type: "mobile" + vendor: "Apple" + model: "iPhone" + engine: + name: "WebKit" + version: "605.1.15" + - + ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Ddg/7.123.0" + spec: + browser: + name: "DuckDuckGo" + version: "7.123.0" + os: + name: "macOS" + version: "10.15.7" + versionName: "Catalina" + platform: + type: "desktop" + vendor: "Apple" + engine: + name: "WebKit" + version: "605.1.15" + - + ua: "Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Ddg/7.120.0" + spec: + browser: + name: "DuckDuckGo" + version: "7.120.0" + os: + name: "iOS" + version: "17.5" + platform: + type: "tablet" + vendor: "Apple" + model: "iPad" + engine: + name: "WebKit" + version: "605.1.15" InternetArchiveCrawler: - ua: "ia_archiver" diff --git a/test/unit/parser.js b/test/unit/parser.js index 66036fd..3a78014 100644 --- a/test/unit/parser.js +++ b/test/unit/parser.js @@ -228,3 +228,158 @@ test('Parser.isEngine should pass', (t) => { t.is(parser.isEngine('blink'), true); t.is(parser.isEngine('webkit'), false); }); + +// Client Hints tests +const DDG_ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.200 Mobile Safari/537.36'; +const DDG_ANDROID_HINTS = { + brands: [ + { brand: 'DuckDuckGo', version: '5.225.1' }, + { brand: 'Chromium', version: '131' }, + { brand: 'Not)A;Brand', version: '99' }, + ], + mobile: true, + platform: 'Android', + platformVersion: '14', +}; + +const CHROME_HINTS = { + brands: [ + { brand: 'Google Chrome', version: '131' }, + { brand: 'Chromium', version: '131' }, + { brand: 'Not_A Brand', version: '24' }, + ], + mobile: false, + platform: 'Windows', + platformVersion: '15.0.0', +}; + +const EDGE_HINTS = { + brands: [ + { brand: 'Microsoft Edge', version: '131' }, + { brand: 'Chromium', version: '131' }, + { brand: 'Not-A.Brand', version: '24' }, + ], + mobile: false, + platform: 'Windows', +}; + +test('Parser.getHints returns null when no hints provided', (t) => { + const p = new Parser(UA, true); + t.is(p.getHints(), null); +}); + +test('Parser.getHints returns hints when provided via constructor', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.deepEqual(p.getHints(), DDG_ANDROID_HINTS); +}); + +test('Parser.getHints with overloaded constructor (UA, hints)', (t) => { + const p = new Parser(DDG_ANDROID_UA, DDG_ANDROID_HINTS); + t.deepEqual(p.getHints(), DDG_ANDROID_HINTS); +}); + +test('Parser.hasBrand returns true for existing brand', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.true(p.hasBrand('DuckDuckGo')); + t.true(p.hasBrand('Chromium')); +}); + +test('Parser.hasBrand is case insensitive', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.true(p.hasBrand('duckduckgo')); + t.true(p.hasBrand('DUCKDUCKGO')); + t.true(p.hasBrand('chromium')); +}); + +test('Parser.hasBrand returns false for non-existent brand', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.false(p.hasBrand('Firefox')); + t.false(p.hasBrand('Safari')); +}); + +test('Parser.hasBrand returns false when no hints provided', (t) => { + const p = new Parser(DDG_ANDROID_UA, true); + t.false(p.hasBrand('DuckDuckGo')); +}); + +test('Parser.hasBrand detects GREASE "Not A Brand" variants', (t) => { + const p1 = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.true(p1.hasBrand('Not)A;Brand')); + + const p2 = new Parser(DDG_ANDROID_UA, false, CHROME_HINTS); + t.true(p2.hasBrand('Not_A Brand')); + + const p3 = new Parser(DDG_ANDROID_UA, false, EDGE_HINTS); + t.true(p3.hasBrand('Not-A.Brand')); +}); + +test('Parser.getBrandVersion returns version for existing brand', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.is(p.getBrandVersion('DuckDuckGo'), '5.225.1'); + t.is(p.getBrandVersion('Chromium'), '131'); +}); + +test('Parser.getBrandVersion is case insensitive', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.is(p.getBrandVersion('duckduckgo'), '5.225.1'); + t.is(p.getBrandVersion('CHROMIUM'), '131'); +}); + +test('Parser.getBrandVersion returns undefined for non-existent brand', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.is(p.getBrandVersion('Firefox'), undefined); +}); + +test('Parser.getBrandVersion returns undefined when no hints provided', (t) => { + const p = new Parser(DDG_ANDROID_UA, true); + t.is(p.getBrandVersion('DuckDuckGo'), undefined); +}); + +test('Parser.getBrandVersion returns version for GREASE brands', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.is(p.getBrandVersion('Not)A;Brand'), '99'); +}); + +test('Parser detects DuckDuckGo from client hints brands', (t) => { + const p = new Parser(DDG_ANDROID_UA, false, DDG_ANDROID_HINTS); + t.is(p.getBrowserName(), 'DuckDuckGo'); + t.is(p.getBrowserVersion(), '5.225.1'); +}); + +test('Parser falls back to UA when no client hints provided', (t) => { + const p = new Parser(DDG_ANDROID_UA); + // Without hints, Chrome is detected from the UA string + t.is(p.getBrowserName(), 'Chrome'); + t.is(p.getBrowserVersion(), '131.0.6778.200'); +}); + +test('Parser with empty brands array falls back to UA parsing', (t) => { + const emptyHints = { brands: [], mobile: true, platform: 'Android' }; + const p = new Parser(DDG_ANDROID_UA, false, emptyHints); + t.false(p.hasBrand('DuckDuckGo')); + t.is(p.getBrowserName(), 'Chrome'); +}); + +test('Parser handles malformed hints gracefully', (t) => { + const malformedHints = { brands: [{ brand: null }, { version: '1.0' }, {}] }; + const p = new Parser(DDG_ANDROID_UA, false, malformedHints); + t.false(p.hasBrand('DuckDuckGo')); + t.is(p.getBrandVersion('anything'), undefined); +}); + +test('Parser with Chrome client hints', (t) => { + const chromeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; + const p = new Parser(chromeUA, false, CHROME_HINTS); + t.true(p.hasBrand('Google Chrome')); + t.true(p.hasBrand('Chromium')); + t.true(p.hasBrand('Not_A Brand')); + t.is(p.getBrandVersion('Google Chrome'), '131'); +}); + +test('Parser with Edge client hints', (t) => { + const edgeUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0'; + const p = new Parser(edgeUA, false, EDGE_HINTS); + t.true(p.hasBrand('Microsoft Edge')); + t.true(p.hasBrand('Chromium')); + t.is(p.getBrandVersion('Microsoft Edge'), '131'); +});