From b4364c7034959bab01dac889ba4b4473328ce024 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Wed, 28 Jan 2026 16:29:03 +0000 Subject: [PATCH] Add DuckDuckGo browser detection --- index.d.ts | 48 +++++++++++++++-- src/bowser.js | 30 ++++++++--- src/constants.js | 2 + src/parser-browsers.js | 32 ++++++++++++ src/parser.js | 77 ++++++++++++++++++++++++++-- test/acceptance/useragentstrings.yml | 45 ++++++++++++++++ 6 files changed, 217 insertions(+), 17 deletions(-) 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 6480e1b..1c442d5 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', @@ -81,6 +82,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 a00dbeb..989187c 100644 --- a/src/parser-browsers.js +++ b/src/parser-browsers.js @@ -970,6 +970,38 @@ const browsersList = [ return browser; }, }, + /* DuckDuckGo Browser */ + { + test(parser) { + // WebKit platforms (iOS, macOS): check UA string for Ddg/version suffix + const isWebKitDDG = parser.test(/\sDdg\/[\d.]+$/i); + // Chromium platforms (Android, Windows): check Client Hints brands + const isChromiumDDG = parser.hasBrand('DuckDuckGo'); + return isWebKitDDG || isChromiumDDG; + }, + describe(ua, parser) { + const browser = { + name: 'DuckDuckGo', + }; + + // Try WebKit UA pattern first + const uaVersion = Utils.getFirstMatch(/\sDdg\/([\d.]+)$/i, ua); + if (uaVersion) { + browser.version = uaVersion; + return browser; + } + + // Try Client Hints brand version + if (parser) { + const hintsVersion = parser.getBrandVersion('DuckDuckGo'); + if (hintsVersion) { + browser.version = hintsVersion; + } + } + + return browser; + }, + }, { test: [/chromium/i], describe(ua) { diff --git a/src/parser.js b/src/parser.js index db10657..8a5131e 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 (typeof skipParsingOrHints === 'object' && skipParsingOrHints !== null) { + 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 8f99243..f291155 100644 --- a/test/acceptance/useragentstrings.yml +++ b/test/acceptance/useragentstrings.yml @@ -2927,6 +2927,51 @@ 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" + - + 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" + engine: + name: "WebKit" + - + 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" InternetArchiveCrawler: - ua: "ia_archiver"