├── .babelrc ├── .circleci └── config.yml ├── .clabot ├── .dir-locals.el ├── .eslintrc ├── .github └── pull_request_template.md ├── .gitignore ├── .mocharc.cjs ├── .prettierignore ├── .prettierrc ├── .tool-versions ├── CLA.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── media └── ycombinator.png ├── package.json ├── src ├── gunzip-data.ts ├── index.ts ├── update-data.ts ├── user-agent.ts └── user-agents.json.gz ├── test └── test-user-agent.js ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "testing": { 4 | "presets": [ 5 | ["@babel/preset-env", { 6 | "targets": { 7 | "node": "current" 8 | } 9 | }], 10 | "@babel/preset-typescript", 11 | "power-assert" 12 | ] 13 | } 14 | }, 15 | "presets": [ 16 | [ 17 | "@babel/preset-env", 18 | { 19 | "modules": false, 20 | "targets": { 21 | "browsers": [ 22 | "last 2 chrome versions", 23 | "last 2 firefox versions", 24 | ], 25 | "node": "6.10" 26 | } 27 | } 28 | ], 29 | "@babel/preset-typescript" 30 | ], 31 | "plugins": [ 32 | "@babel/plugin-proposal-class-properties", 33 | "@babel/plugin-proposal-object-rest-spread", 34 | "@babel/plugin-syntax-import-assertions", 35 | "@babel/plugin-transform-classes", 36 | "babel-plugin-add-module-exports" 37 | ] 38 | } 39 | 40 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/user-agents 3 | docker: 4 | - image: cimg/node:21.1 5 | 6 | whitelist: &whitelist 7 | paths: 8 | - .babelrc 9 | - .circleci/* 10 | - .clabot 11 | - .dir-locals.el 12 | - .eslintrc 13 | - .git/* 14 | - .github/* 15 | - .gitignore 16 | - .mocharc.cjs 17 | - .prettierignore 18 | - .prettierrc 19 | - .tool-versions 20 | - CLA.md 21 | - CONTRIBUTING.md 22 | - LICENSE 23 | - README.md 24 | - dist/* 25 | - media/* 26 | - package.json 27 | - src/* 28 | - test/* 29 | - tsconfig.json 30 | - tsup.config.ts 31 | - yarn.lock 32 | 33 | version: 2 34 | jobs: 35 | checkout: 36 | <<: *defaults 37 | steps: 38 | - checkout 39 | - restore_cache: 40 | key: dependency-cache-v2-{{ checksum "yarn.lock" }} 41 | - run: 42 | name: Install Dependencies 43 | command: yarn install 44 | - save_cache: 45 | key: dependency-cache-v2-{{ checksum "yarn.lock" }} 46 | paths: 47 | - ./node_modules 48 | - persist_to_workspace: 49 | root: ~/user-agents 50 | <<: *whitelist 51 | 52 | lint: 53 | <<: *defaults 54 | steps: 55 | - attach_workspace: 56 | at: ~/user-agents 57 | - restore_cache: 58 | key: dependency-cache-v2-{{ checksum "yarn.lock" }} 59 | - run: 60 | name: Lint 61 | command: | 62 | yarn lint 63 | - persist_to_workspace: 64 | root: ~/user-agents 65 | <<: *whitelist 66 | 67 | build: 68 | <<: *defaults 69 | steps: 70 | - attach_workspace: 71 | at: ~/user-agents 72 | - restore_cache: 73 | key: dependency-cache-v2-{{ checksum "yarn.lock" }} 74 | - run: 75 | name: Build 76 | command: | 77 | yarn gunzip-data 78 | yarn build 79 | - persist_to_workspace: 80 | root: ~/user-agents 81 | <<: *whitelist 82 | 83 | test: 84 | <<: *defaults 85 | steps: 86 | - attach_workspace: 87 | at: ~/user-agents 88 | - restore_cache: 89 | key: dependency-cache-v2-{{ checksum "yarn.lock" }} 90 | - run: 91 | name: Test 92 | command: | 93 | yarn test 94 | 95 | deploy: 96 | <<: *defaults 97 | steps: 98 | - attach_workspace: 99 | at: ~/user-agents 100 | - run: 101 | name: Write NPM Token to ~/.npmrc 102 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc 103 | - run: 104 | name: Install dot-json package 105 | command: npm install --no-save dot-json 106 | - run: 107 | name: Write version to package.json 108 | command: $(yarn bin)/dot-json package.json version ${CIRCLE_TAG:1} 109 | - run: 110 | name: Publish to NPM 111 | command: | 112 | # Find the current package version, for example: `v2.0.0-alpha.0` or `v2.0.0`. 113 | CURRENT_VERSION=$(jq -r .version package.json) 114 | echo "Current Version: ${CURRENT_VERSION}" 115 | 116 | if [[ $CURRENT_VERSION == *-* ]]; then 117 | # A hyphen means we're a prelease and want to tag this as `next`. 118 | npm publish --access=public --tag next 119 | else 120 | # Otherwise, this is a normal release and we want to tag this as `latest`. 121 | npm publish --access=public 122 | fi 123 | 124 | update: 125 | <<: *defaults 126 | steps: 127 | - attach_workspace: 128 | at: ~/user-agents 129 | - restore_cache: 130 | key: dependency-cache-v2-{{ checksum "yarn.lock" }} 131 | - run: 132 | name: Update the user agents data 133 | no_output_timeout: 30m 134 | command: | 135 | yarn update-data 136 | - store_artifacts: 137 | path: ~/user-agents/src/user-agents.json.gz 138 | destination: user-agents.json.gz 139 | - persist_to_workspace: 140 | root: ~/user-agents 141 | <<: *whitelist 142 | 143 | publish-new-version: 144 | <<: *defaults 145 | steps: 146 | - attach_workspace: 147 | at: ~/user-agents 148 | - run: 149 | name: Commit the newly downloaded data 150 | command: | 151 | git add src/user-agents.json.gz 152 | # Configure some identity details for the machine deployment account. 153 | git config --global user.email "user-agents@intoli.com" 154 | git config --global user.name "User Agents" 155 | git config --global push.default "simple" 156 | # Disable strict host checking. 157 | mkdir -p ~/.ssh/ 158 | echo -e "Host github.com\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config 159 | # The status code will be 1 if there are no changes, 160 | # but we want to publish anyway to stay on a regular schedule. 161 | git commit -m 'Regularly scheduled user agent data update.' || true 162 | - run: 163 | name: Bump the patch (or prelease) version and trigger a new release 164 | command: | 165 | echo "Attempting to bump version and trigger a new release..." 166 | 167 | # Find the current package version, for example: `v2.0.0-alpha.0` or `v2.0.0`. 168 | CURRENT_VERSION=$(jq -r .version package.json) 169 | echo "Current Version: ${CURRENT_VERSION}" 170 | 171 | if [[ $CURRENT_VERSION == *-* ]]; then 172 | # A hyphen means we're a prelease and want to bump the prerelease version. 173 | echo "Prerelease detected, bumping prerelease version." 174 | npm version prerelease 175 | else 176 | # Otherwise, this is a normal release and we bump the patch version. 177 | echo "Prerelease not detected, bumping patch version." 178 | npm version patch 179 | fi 180 | 181 | echo "Pushing changes to the main branch." 182 | git push --set-upstream origin main 183 | echo "Pushing tags." 184 | git push --tags 185 | echo "Success!" 186 | 187 | workflows: 188 | version: 2 189 | 190 | build: 191 | jobs: 192 | - checkout 193 | - lint: 194 | filters: 195 | tags: 196 | ignore: /v[0-9]+(\.[0-9]+)*.*/ 197 | requires: 198 | - checkout 199 | - build: 200 | filters: 201 | tags: 202 | ignore: /v[0-9]+(\.[0-9]+)*.*/ 203 | requires: 204 | - checkout 205 | - test: 206 | filters: 207 | tags: 208 | ignore: /v[0-9]+(\.[0-9]+)*.*/ 209 | requires: 210 | - build 211 | 212 | release: 213 | jobs: 214 | - checkout: 215 | filters: 216 | tags: 217 | only: /v[2-9](\.[0-9]+)*.*/ 218 | branches: 219 | ignore: /.*/ 220 | - lint: 221 | filters: 222 | tags: 223 | only: /v[2-9](\.[0-9]+)*.*/ 224 | branches: 225 | ignore: /.*/ 226 | requires: 227 | - checkout 228 | - build: 229 | filters: 230 | tags: 231 | only: /v[2-9](\.[0-9]+)*.*/ 232 | branches: 233 | ignore: /.*/ 234 | requires: 235 | - lint 236 | - test: 237 | filters: 238 | tags: 239 | only: /v[2-9](\.[0-9]+)*.*/ 240 | branches: 241 | ignore: /.*/ 242 | requires: 243 | - build 244 | - deploy: 245 | filters: 246 | tags: 247 | only: /v[2-9](\.[0-9]+)*.*/ 248 | branches: 249 | ignore: /.*/ 250 | requires: 251 | - test 252 | 253 | scheduled-release: 254 | triggers: 255 | - schedule: 256 | cron: '30 06 * * *' 257 | filters: 258 | branches: 259 | only: 260 | - main 261 | 262 | jobs: 263 | - checkout 264 | - update: 265 | requires: 266 | - checkout 267 | - build: 268 | requires: 269 | - update 270 | - test: 271 | requires: 272 | - build 273 | - publish-new-version: 274 | requires: 275 | - test 276 | -------------------------------------------------------------------------------- /.clabot: -------------------------------------------------------------------------------- 1 | { 2 | "contributors": [ 3 | "sangaline", 4 | "HugoPoi" 5 | ], 6 | "message": "Thank you for making a contribution! We require new contributors to sign our [Contributor License Agreement (CLA)](https://github.com/intoli/user-agents/blob/master/CLA.md). Please review our [Contributing Guide](https://github.com/intoli/user-agents/blob/master/CONTRIBUTING.md) and make sure that you've followed the steps listed there." 7 | } 8 | -------------------------------------------------------------------------------- /.dir-locals.el: -------------------------------------------------------------------------------- 1 | ( 2 | ; Set the JavaScript checkers. 3 | ; Run `flycheck-verify-setup` to see the current status. 4 | (js-mode . ((flycheck-checker . javascript-eslint) 5 | (flycheck-disabled-checkers . (lsp javascript-jshint javascript-standard)))) 6 | (typescript-mode . ((flycheck-checker . javascript-eslint) 7 | (flycheck-disabled-checkers . (lsp javascript-jshint javascript-standard)))) 8 | ) 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "mocha": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier", 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "plugins": ["@typescript-eslint"], 14 | "rules": { 15 | "import/extensions": [ 16 | "error", 17 | "never", 18 | { 19 | "js": "always", 20 | "json": "always" 21 | } 22 | ], 23 | "import/no-import-module-exports": "off", 24 | "import/no-unresolved": "off", 25 | "no-await-in-loop": "off", 26 | "no-param-reassign": [ 27 | "error", 28 | { 29 | "props": false 30 | } 31 | ], 32 | "no-prototype-builtins": "off", 33 | "no-use-before-define": "off", 34 | '@typescript-eslint/no-use-before-define': ['error', { "typedefs": false }], 35 | "object-curly-newline": [ 36 | "error", 37 | { 38 | "consistent": true, 39 | "minProperties": 5, 40 | } 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | *the pull request description goes here...* 2 | 3 | 4 | **Please check the boxes below to confirm that you have completed these steps:** 5 | 6 | * [ ] I have read and followed the [Contribution Agreement](https://github.com/intoli/user-agents/blob/master/CONTRIBUTING.md). 7 | * [ ] I have signed the project [Contributor License Agreement](https://github.com/intoli/user-agents/blob/master/CONTRIBUTING.md). 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .ga-runner-user-agents-npm-package-update_user-agents-npm-package_iam_gserviceaccount_com 2 | .tern-port 3 | dist/ 4 | google-analytics-credentials.json 5 | node_modules/ 6 | src/user-agents.json 7 | yarn-error.log 8 | -------------------------------------------------------------------------------- /.mocharc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | loader: 'ts-node/esm', 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | yarn.lock 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 21.1.0 2 | yarn 1.22.19 3 | -------------------------------------------------------------------------------- /CLA.md: -------------------------------------------------------------------------------- 1 | Contributor Agreement 2 | --------------------- 3 | 4 | Individual Contributor Exclusive License Agreement 5 | -------------------------------------------------- 6 | 7 | (including the Traditional Patent License OPTION) 8 | ------------------------------------------------- 9 | 10 | Thank you for your interest in contributing to Intoli, LLC's User-Agents ("We" or "Us"). 11 | 12 | The purpose of this contributor agreement ("Agreement") is to clarify and document the rights granted by contributors to Us. To make this document effective, please follow the instructions at https://github.com/intoli/user-agents/blob/master/CONTRIBUTING.md. 13 | 14 | ### How to use this Contributor Agreement 15 | 16 | If You are an employee and have created the Contribution as part of your employment, You need to have Your employer approve this Agreement or sign the Entity version of this document. If You do not own the Copyright in the entire work of authorship, any other author of the Contribution should also sign this – in any event, please contact Us at open-source@intoli.com 17 | 18 | ### 1\. Definitions 19 | 20 | **"You"** means the individual Copyright owner who Submits a Contribution to Us. 21 | 22 | **"Legal Entity"** means an entity that is not a natural person. 23 | 24 | **"Affiliate"** means any other Legal Entity that controls, is controlled by, or under common control with that Legal Entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such Legal Entity, whether by contract or otherwise, (ii) ownership of fifty percent (50%) or more of the outstanding shares or securities that vote to elect the management or other persons who direct such Legal Entity or (iii) beneficial ownership of such entity. 25 | 26 | **"Contribution"** means any original work of authorship, including any original modifications or additions to an existing work of authorship, Submitted by You to Us, in which You own the Copyright. 27 | 28 | **"Copyright"** means all rights protecting works of authorship, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence. 29 | 30 | **"Material"** means the software or documentation made available by Us to third parties. When this Agreement covers more than one software project, the Material means the software or documentation to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. 31 | 32 | **"Submit"** means any act by which a Contribution is transferred to Us by You by means of tangible or intangible media, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us, but excluding any transfer that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." 33 | 34 | **"Documentation"** means any non-software portion of a Contribution. 35 | 36 | ### 2\. License grant 37 | 38 | #### 2.1 Copyright license to Us 39 | 40 | Subject to the terms and conditions of this Agreement, You hereby grant to Us a worldwide, royalty-free, Exclusive, perpetual and irrevocable (except as stated in Section 8.2) license, with the right to transfer an unlimited number of non-exclusive licenses or to grant sublicenses to third parties, under the Copyright covering the Contribution to use the Contribution by all means, including, but not limited to: 41 | 42 | * publish the Contribution, 43 | * modify the Contribution, 44 | * prepare derivative works based upon or containing the Contribution and/or to combine the Contribution with other Materials, 45 | * reproduce the Contribution in original or modified form, 46 | * distribute, to make the Contribution available to the public, display and publicly perform the Contribution in original or modified form. 47 | 48 | #### 2.2 Moral rights 49 | 50 | Moral Rights remain unaffected to the extent they are recognized and not waivable by applicable law. Notwithstanding, You may add your name to the attribution mechanism customary used in the Materials you Contribute to, such as the header of the source code files of Your Contribution, and We will respect this attribution when using Your Contribution. 51 | 52 | #### 2.3 Copyright license back to You 53 | 54 | Upon such grant of rights to Us, We immediately grant to You a worldwide, royalty-free, non-exclusive, perpetual and irrevocable license, with the right to transfer an unlimited number of non-exclusive licenses or to grant sublicenses to third parties, under the Copyright covering the Contribution to use the Contribution by all means, including, but not limited to: 55 | 56 | * publish the Contribution, 57 | * modify the Contribution, 58 | * prepare derivative works based upon or containing the Contribution and/or to combine the Contribution with other Materials, 59 | * reproduce the Contribution in original or modified form, 60 | * distribute, to make the Contribution available to the public, display and publicly perform the Contribution in original or modified form. 61 | 62 | This license back is limited to the Contribution and does not provide any rights to the Material. 63 | 64 | ### 3\. Patents 65 | 66 | #### 3.1 Patent license 67 | 68 | Subject to the terms and conditions of this Agreement You hereby grant to Us and to recipients of Materials distributed by Us a worldwide, royalty-free, non-exclusive, perpetual and irrevocable (except as stated in Section 3.2) patent license, with the right to transfer an unlimited number of non-exclusive licenses or to grant sublicenses to third parties, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with any Material (and portions of such combination). This license applies to all patents owned or controlled by You, whether already acquired or hereafter acquired, that would be infringed by making, having made, using, selling, offering for sale, importing or otherwise transferring of Your Contribution(s) alone or by combination of Your Contribution(s) with any Material. 69 | 70 | #### 3.2 Revocation of patent license 71 | 72 | You reserve the right to revoke the patent license stated in section 3.1 if We make any infringement claim that is targeted at your Contribution and not asserted for a Defensive Purpose. An assertion of claims of the Patents shall be considered for a "Defensive Purpose" if the claims are asserted against an entity that has filed, maintained, threatened, or voluntarily participated in a patent infringement lawsuit against Us or any of Our licensees. 73 | 74 | ### 4. Disclaimer 75 | 76 | THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF SATISFACTORY QUALITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US AND BY US TO YOU. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION AND EXTENT TO THE MINIMUM PERIOD AND EXTENT PERMITTED BY LAW. 77 | 78 | ### 5. Consequential damage waiver 79 | 80 | TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU OR WE BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. 81 | 82 | ### 6. Approximation of disclaimer and damage waiver 83 | 84 | IF THE DISCLAIMER AND DAMAGE WAIVER MENTIONED IN SECTION 4. AND SECTION 5. CANNOT BE GIVEN LEGAL EFFECT UNDER APPLICABLE LOCAL LAW, REVIEWING COURTS SHALL APPLY LOCAL LAW THAT MOST CLOSELY APPROXIMATES AN ABSOLUTE WAIVER OF ALL CIVIL OR CONTRACTUAL LIABILITY IN CONNECTION WITH THE CONTRIBUTION. 85 | 86 | ### 7. Term 87 | 88 | 7.1 This Agreement shall come into effect upon Your acceptance of the terms and conditions. 89 | 90 | 7.3 In the event of a termination of this Agreement Sections 4, 5, 6, 7 and 8 shall survive such termination and shall remain in full force thereafter. For the avoidance of doubt, Free and Open Source Software (sub)licenses that have already been granted for Contributions at the date of the termination shall remain in full force after the termination of this Agreement. 91 | 92 | ### 8 Miscellaneous 93 | 94 | 8.1 This Agreement and all disputes, claims, actions, suits or other proceedings arising out of this agreement or relating in any way to it shall be governed by the laws of United States excluding its private international law provisions. 95 | 96 | 8.2 This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings. 97 | 98 | 8.3 In case of Your death, this agreement shall continue with Your heirs. In case of more than one heir, all heirs must exercise their rights through a commonly authorized person. 99 | 100 | 8.4 If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and that is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. 101 | 102 | 8.5 You agree to notify Us of any facts or circumstances of which you become aware that would make this Agreement inaccurate in any respect. 103 | 104 | ### You 105 | 106 | Date: 107 | 108 | Name: 109 | 110 | Title: 111 | 112 | Address: 113 | 114 | ### Us 115 | 116 | Date: 117 | 118 | Name: 119 | 120 | Title: 121 | 122 | Address: 123 | 124 | #### Recreate this Contributor License Agreement 125 | 126 | [https://contributoragreements.org/ca-cla-chooser/?beneficiary-name=Intoli%2C+LLC&project-name=User-Agents&project-website=https%3A%2F%2Fgithub.com%2Fintoli%2Fuser-agents&project-email=open-source%40intoli.com&process-url=https%3A%2F%2Fgithub.com%2Fintoli%2Fuser-agents%2Fblob%2Fmaster%2FCONTRIBUTING.md&project-jurisdiction=United+States&agreement-exclusivity=exclusive&fsfe-compliance=&fsfe-fla=&outbound-option=no-commitment&outboundlist=&outboundlist-custom=&medialist=\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_&patent-option=Traditional&your-date=&your-name=&your-title=&your-address=&your-patents=&pos=apply&action=](https://contributoragreements.org/ca-cla-chooser/?beneficiary-name=Intoli%2C+LLC&project-name=User-Agents&project-website=https%3A%2F%2Fgithub.com%2Fintoli%2Fuser-agents&project-email=open-source%40intoli.com&process-url=https%3A%2F%2Fgithub.com%2Fintoli%2Fuser-agents%2Fblob%2Fmaster%2FCONTRIBUTING.md&project-jurisdiction=United+States&agreement-exclusivity=exclusive&fsfe-compliance=&fsfe-fla=&outbound-option=no-commitment&outboundlist=&outboundlist-custom=&medialist=____________________&patent-option=Traditional&your-date=&your-name=&your-title=&your-address=&your-patents=&pos=apply&action=) 127 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are welcome, but please follow these contributor guidelines: 4 | 5 | - Create an issue on [the issue tracker](https://github.com/intoli/user-agents/issues/new) to discuss potential changes before submitting a pull request. 6 | - Include at least one test to cover any new functionality or bug fixes. 7 | - Make sure that all of your tests are passing and that there are no merge conflicts. 8 | - Print, sign, and email the [Contributor License Agreement](https://github.com/intoli/user-agents/blob/master/CLA.md) to [open-source@intoli.com](mailto:open-source@intoli.com). 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2-Clause BSD License 2 | 3 | Copyright 2018-present - Intoli, LLC 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 19 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 20 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 22 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 23 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 24 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | User Agents 3 |

4 | 5 |

6 | 7 | Build Status 9 | 10 | Build Status 12 | 13 | License 15 | 16 | NPM Version 18 |          19 | 20 | Tweet 22 | 23 | Share on Facebook 25 | 26 | Share on Reddit 28 | 29 | Share on Hacker News 31 |

32 | 33 | 34 | ###### [Installation](#installation) | [Examples](#examples) | [API](#api) | [How it Works](https://intoli.com/blog/user-agents/) | [Contributing](#contributing) 35 | 36 | > User-Agents is a JavaScript package for generating random [User Agents](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) based on how frequently they're used in the wild. 37 | > A new version of the package is automatically released every day, so the data is always up to date. 38 | > The generated data includes hard to find browser-fingerprint properties, and powerful filtering capabilities allow you to restrict the generated user agents to fit your exact needs. 39 | 40 | Web scraping often involves creating realistic traffic patterns, and doing so generally requires a good source of data. 41 | The User-Agents package provides a comprehensive dataset of real-world user agents and other browser properties which are commonly used for browser fingerprinting and blocking automated web browsers. 42 | Unlike other random user agent generation libraries, the User-Agents package is updated automatically on a daily basis. 43 | This means that you can use it without worrying about whether the data will be stale in a matter of months. 44 | 45 | Generating a realistic random user agent is as simple as running `new UserAgent()`, but you can also easily generate user agents which correspond to a specific platform, device category, or even operating system version. 46 | The fastest way to get started is to hop down to the [Examples](#examples) section where you can see it in action! 47 | 48 | 49 | ## Installation 50 | 51 | The User Agents package is available on npm with the package name [user-agents](https://npmjs.com/package/user-agents). 52 | You can install it using your favorite JavaScript package manager in the usual way. 53 | 54 | ```bash 55 | # With npm: npm install user-agents 56 | # With pnpm: pnpm install user-agents 57 | # With yarn: 58 | yarn add user-agents 59 | ``` 60 | 61 | 62 | ## Examples 63 | 64 | The User-Agents library offers a very flexible interface for generating user agents. 65 | These examples illustrate some common use cases, and show how the filtering API can be used in practice. 66 | 67 | 68 | ### Generating a Random User Agent 69 | 70 | The most basic usage involves simply instantiating a `UserAgent` instance. 71 | It will be automatically populated with a random user agent and browser fingerprint. 72 | 73 | 74 | ```javascript 75 | import UserAgent from 'user-agents'; 76 | 77 | 78 | const userAgent = new UserAgent(); 79 | console.log(userAgent.toString()); 80 | console.log(JSON.stringify(userAgent.data, null, 2)); 81 | ``` 82 | 83 | In this example, we've generated a random user agent and then logged out stringified versions both the `userAgent.data` object and `userAgent` itself to the console. 84 | An example output might look something like this. 85 | 86 | ```literal 87 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36 88 | ``` 89 | 90 | ```json 91 | { 92 | "appName": "Netscape", 93 | "connection": { 94 | "downlink": 10, 95 | "effectiveType": "4g", 96 | "rtt": 0 97 | }, 98 | "platform": "Win32", 99 | "pluginsLength": 3, 100 | "vendor": "Google Inc.", 101 | "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36", 102 | "viewportHeight": 660, 103 | "viewportWidth": 1260, 104 | "deviceCategory": "desktop", 105 | "screenHeight": 800, 106 | "screenWidth": 1280 107 | } 108 | ``` 109 | 110 | The `userAgent.toString()` call converts the user agent into a string which corresponds to the actual user agent. 111 | The `data` property includes a randomly generated browser fingerprint that can be used for more detailed emulation. 112 | 113 | 114 | ### Restricting Device Categories 115 | 116 | By passing an object as a filter, each corresponding user agent property will be restricted based on its values. 117 | 118 | ```javascript 119 | import UserAgent from 'user-agents'; 120 | 121 | const userAgent = new UserAgent({ deviceCategory: 'mobile' }) 122 | ``` 123 | 124 | This code will generate a user agent with a `deviceCategory` of `mobile`. 125 | If you replace `mobile` with either `desktop` or `tablet`, then the user agent will correspond to one of those device types instead. 126 | 127 | 128 | ### Generating Multiple User Agents With The Same Filters 129 | 130 | There is some computational overhead involved with applying a set of filters, so it's far more efficient to reuse the filter initialization when you need to generate many user agents with the same configuration. 131 | You can call any initialized `UserAgent` instance like a function, and it will generate a new random instance with the same filters (you can also call `userAgent.random()` if you're not a fan of the shorthand). 132 | 133 | ```javascript 134 | import UserAgent from 'user-agents'; 135 | 136 | const userAgent = new UserAgent({ platform: 'Win32' }); 137 | const userAgents = Array(1000).fill().map(() => userAgent()); 138 | ``` 139 | 140 | This code example initializes a single user agent with a filter that limits the platform to `Win32`, and then uses that instance to generate 1000 more user agents with the same filter. 141 | 142 | 143 | ### Regular Expression Matching 144 | 145 | You can pass a regular expression as a filter and the generated user agent will be guaranteed to match that regular expression. 146 | 147 | ```javascript 148 | import UserAgent from 'user-agents'; 149 | 150 | const userAgent = new UserAgent(/Safari/); 151 | ``` 152 | 153 | This example will generate a user agent that contains a `Safari` substring. 154 | 155 | 156 | ### Custom Filter Functions 157 | 158 | It's also possible to implement completely custom logic by using a filter as a function. 159 | The raw `userAgent.data` object will be passed into your function, and it will be included as a possible candidate only if your function returns `true`. 160 | In this example, we'll use the [useragent](https://www.npmjs.com/package/useragent) package to parse the user agent string and then restrict the generated user agents to iOS devices with an operating system version of 11 or greater. 161 | 162 | ```javascript 163 | import UserAgent from 'user-agents'; 164 | import { parse } from 'useragent'; 165 | 166 | const userAgent = new UserAgent((data) => { 167 | const os = parse(data.userAgent).os; 168 | return os.family === 'iOS' && parseInt(os.major, 10) > 11; 169 | }); 170 | ``` 171 | 172 | The filtering that you apply here is completely up to you, so there's really no limit to how specific it can be. 173 | 174 | 175 | ### Combining Filters With Arrays 176 | 177 | You can also use arrays to specify collections of filters that will all be applied. 178 | This example combines a regular expression filter with an object filter to generate a user agent with a connection type of `wifi`, a platform of `MacIntel`, and a user agent that includes a `Safari` substring. 179 | 180 | ```javascript 181 | import UserAgent from 'user-agents'; 182 | 183 | const userAgent = new UserAgent([ 184 | /Safari/, 185 | { 186 | connection: { 187 | type: 'wifi', 188 | }, 189 | platform: 'MacIntel', 190 | }, 191 | ]); 192 | ``` 193 | 194 | This example also shows that you can specify both multiple and nested properties on object filters. 195 | 196 | 197 | ## API 198 | 199 | ### class: UserAgent([filters]) 200 | 201 | - `filters` <`Array`, `Function`, `Object`, `RegExp`, or `String`> - A set of filters to apply to the generated user agents. 202 | The filter specification is extremely flexible, and reading through the [Examples](#examples) section is the best way to familiarize yourself with what sort of filtering is possible. 203 | 204 | `UserAgent` is an object that contains the details of a randomly generated user agent and corresponding browser fingerprint. 205 | Each time the class is instantiated, it will randomly populate the instance with a new user agent based on the specified filters. 206 | The instantiated class can be cast to a user agent string by explicitly calling `toString()`, accessing the `userAgent` property, or implicitly converting the type to a primitive or string in the standard JavaScript ways (*e.g.* `` `${userAgent}` ``). 207 | Other properties can be accessed as outlined below. 208 | 209 | 210 | #### userAgent.random() 211 | 212 | - returns: <`UserAgent`> 213 | 214 | This method generates a new `UserAgent` instance using the same filters that were used to construct `userAgent`. 215 | The following examples both generate two user agents based on the same filters. 216 | 217 | ```javascript 218 | // Explicitly use the constructor twice. 219 | const firstUserAgent = new UserAgent(filters); 220 | const secondUserAgent = new UserAgent(filters); 221 | ``` 222 | 223 | ```javascript 224 | // Use the `random()` method to construct a second user agent. 225 | const firstUserAgent = new UserAgent(filters); 226 | const secondUserAgent = firstUserAgent.random(); 227 | ``` 228 | 229 | The reason to prefer the second pattern is that it reuses the filter processing and preparation of the data for random selection. 230 | Subsequent random generations can easily be over 100x faster than the initial construction. 231 | 232 | 233 | #### userAgent() 234 | 235 | - returns: <`UserAgent`> 236 | 237 | As a bit of syntactic sugar, you can call a `UserAgent` instance like `userAgent()` as a shorthand for `userAgent.random()`. 238 | This allows you to think of the instance as a generator, and lends itself to writing code like this. 239 | 240 | ```javascript 241 | const generateUserAgent = new UserAgent(filters); 242 | const userAgents = Array(100).fill().map(() => generateUserAgent()); 243 | ``` 244 | 245 | #### userAgent.toString() 246 | 247 | - returns: <`String`> 248 | 249 | Casts the `UserAgent` instance to a string which corresponds to the user agent header. 250 | Equivalent to accessing the `userAgent.userAgent` property. 251 | 252 | 253 | #### userAgent.data 254 | 255 | - returns: <`Object`> 256 | - `appName` <`String`> - The value of [navigator.appName](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorID/appName). 257 | - `connection` <`Object`> - The value of [navigator.connection](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection). 258 | - `cpuClass` <`String`> - The value of [navigator.cpuClass](https://msdn.microsoft.com/en-us/library/ms531090\(v=vs.85\).aspx). 259 | - `deviceCategory` <`String`> - One of `desktop`, `mobile`, or `tablet` depending on the type of device. 260 | - `oscpu` <`String`> - The value of [navigator.oscpu](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/oscpu). 261 | - `platform` <`String`> - The value of [navigator.platform](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorID/platform). 262 | - `pluginsLength` <`Number`> - The value of [navigator.plugins.length](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorPlugins/plugins). 263 | - `screenHeight` <`Number`> - The value of [screen.height](https://developer.mozilla.org/en-US/docs/Web/API/Screen/height). 264 | - `screenWidth` <`Number`> - The value of [screen.width](https://developer.mozilla.org/en-US/docs/Web/API/Screen/width). 265 | - `vendor` <`String`> - The value of [navigator.vendor](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/vendor). 266 | - `userAgent` <`String`> - The value of [navigator.userAgent](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorID/userAgent). 267 | - `viewportHeight` <`Number`> - The value of [window.innerHeight](https://developer.mozilla.org/en-US/docs/Web/API/Window/innerHeight). 268 | - `viewportWidth` <`Number`> - The value of [window.innerWidth](https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth). 269 | 270 | The `userAgent.data` contains the randomly generated fingerprint for the `UserAgent` instance. 271 | Note that each property of `data` is also accessible directly on `userAgent`. 272 | For example, `userAgent.appName` is equivalent to `userAgent.data.appName`. 273 | 274 | 275 | ## Versioning 276 | 277 | The project follows [the Semantic Versioning guidelines](https://semver.org/). 278 | The automated deployments will always correspond to patch versions, and minor versions should not introduce breaking changes. 279 | It's likely that the structure of user agent data will change in the future, and this will correspond to a new major version. 280 | 281 | Please keep in mind that older major versions will cease to be updated after a new major version is released. 282 | You can continue to use older versions of the software, but you'll need to upgrade to get access to the latest data. 283 | 284 | 285 | ## Acknowledgements 286 | 287 | The user agent frequency data used in this library is generously provided by [Intoli](https://intoli.com), the premier residential and smart proxy provider for web scraping. 288 | The details of how the data is updated can be found in the blog post [User-Agents — A random user agent generation library that's always up to date](https://intoli.com/blog/user-agents/). 289 | 290 | If you have a high-traffic website and would like to contribute data to the project, then send us an email at [contact@intoli.com](mailto:contact@intoli.com). 291 | Additional data sources will help make the library more useful, and we'll be happy to add a link to your site in the acknowledgements. 292 | 293 | 294 | ## Contributing 295 | 296 | Contributions are welcome, but please follow these contributor guidelines outlined in [CONTRIBUTING.md](CONTRIBUTING.md). 297 | 298 | 299 | ## License 300 | 301 | User-Agents is licensed under a [BSD 2-Clause License](LICENSE) and is copyright [Intoli, LLC](https://intoli.com). 302 | -------------------------------------------------------------------------------- /media/ycombinator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intoli/user-agents/80eb33db6104243153637155d0a66cb5d8e0013b/media/ycombinator.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user-agents", 3 | "version": "2.0.0-alpha.569", 4 | "description": "A JavaScript library for generating random user agents. ", 5 | "main": "./dist/index.cjs", 6 | "module": "./dist/index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+ssh://git@github.com/intoli/user-agents.git" 10 | }, 11 | "author": "Intoli, LLC ", 12 | "files": [ 13 | "dist/", 14 | "src/**/*", 15 | "!src/user-agents.json", 16 | "!src/user-agents.json.gz" 17 | ], 18 | "exports": { 19 | "import": "./dist/index.js", 20 | "require": "./dist/index.cjs" 21 | }, 22 | "license": "BSD-2-Clause", 23 | "private": false, 24 | "type": "module", 25 | "scripts": { 26 | "build": "tsup", 27 | "postbuild": "yarn gunzip-data && cp src/user-agents.json dist/", 28 | "gunzip-data": "node --loader ts-node/esm src/gunzip-data.ts src/user-agents.json.gz", 29 | "lint": "eslint src/ && prettier --check src/", 30 | "test": "NODE_ENV=testing mocha --exit --require @babel/register --extensions '.ts, .js'", 31 | "update-data": "node --loader ts-node/esm src/update-data.ts src/user-agents.json.gz" 32 | }, 33 | "devDependencies": { 34 | "@babel/cli": "^7.23.0", 35 | "@babel/core": "^7.23.2", 36 | "@babel/plugin-proposal-class-properties": "^7.18.6", 37 | "@babel/plugin-proposal-object-rest-spread": "^7.20.7", 38 | "@babel/plugin-syntax-import-assertions": "^7.22.5", 39 | "@babel/plugin-transform-classes": "^7.22.15", 40 | "@babel/preset-env": "^7.23.2", 41 | "@babel/preset-typescript": "^7.23.2", 42 | "@babel/register": "^7.22.15", 43 | "@types/lodash.clonedeep": "^4.5.8", 44 | "@types/ua-parser-js": "^0.7.38", 45 | "@typescript-eslint/eslint-plugin": "^6.9.0", 46 | "@typescript-eslint/parser": "^6.9.0", 47 | "babel-loader": "^9.1.3", 48 | "babel-plugin-add-module-exports": "^1.0.4", 49 | "babel-preset-power-assert": "^3.0.0", 50 | "dynamoose": "^3.2.1", 51 | "esbuild": "^0.19.5", 52 | "eslint": "^8.52.0", 53 | "eslint-config-airbnb": "^19.0.4", 54 | "eslint-config-prettier": "^9.0.0", 55 | "eslint-plugin-import": "^2.29.0", 56 | "fast-json-stable-stringify": "^2.1.0", 57 | "imports-loader": "^4.0.1", 58 | "isbot": "^3.7.0", 59 | "mocha": "^10.2.0", 60 | "power-assert": "^1.6.1", 61 | "prettier": "^3.0.3", 62 | "random": "^4.1.0", 63 | "source-map-support": "^0.5.21", 64 | "ts-node": "^10.9.1", 65 | "tsup": "^7.2.0", 66 | "typescript": "^5.2.2", 67 | "typescript-language-server": "^4.0.0", 68 | "ua-parser-js": "^1.0.36" 69 | }, 70 | "dependencies": { 71 | "lodash.clonedeep": "^4.5.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/gunzip-data.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { argv } from 'process'; 3 | import { fileURLToPath } from 'url'; 4 | import { gunzipSync } from 'zlib'; 5 | 6 | const gunzipData = (inputFilename?: string) => { 7 | if (!inputFilename || !inputFilename.endsWith('.gz')) { 8 | throw new Error('Filename must be specified and end with `.gz` for gunzipping.'); 9 | } 10 | const outputFilename = inputFilename.slice(0, -3); 11 | const compressedData = fs.readFileSync(inputFilename); 12 | const data = gunzipSync(compressedData); 13 | fs.writeFileSync(outputFilename, data); 14 | }; 15 | 16 | if (fileURLToPath(import.meta.url) === argv[1]) { 17 | const inputFilename = process.argv[2]; 18 | gunzipData(inputFilename); 19 | } 20 | 21 | export default gunzipData; 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Filter, UserAgent, UserAgentData } from './user-agent'; 2 | 3 | export default UserAgent; 4 | export type { Filter, UserAgentData }; 5 | -------------------------------------------------------------------------------- /src/update-data.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import fs from 'fs'; 3 | import { argv } from 'process'; 4 | import { fileURLToPath } from 'url'; 5 | import { gzipSync } from 'zlib'; 6 | 7 | import dynamoose from 'dynamoose'; 8 | import { Item } from 'dynamoose/dist/Item.js'; 9 | import stableStringify from 'fast-json-stable-stringify'; 10 | import isbot from 'isbot'; 11 | import random from 'random'; 12 | import UAParser from 'ua-parser-js'; 13 | 14 | import { UserAgentData } from './user-agent'; 15 | 16 | const ddb = new dynamoose.aws.ddb.DynamoDB({ 17 | region: 'us-east-2', 18 | }); 19 | dynamoose.aws.ddb.set(ddb); 20 | 21 | const SubmissionModel = dynamoose.model< 22 | { 23 | ip: string; 24 | profile: { [key: string]: unknown }; 25 | } & UserAgentData & 26 | Item 27 | >( 28 | 'userAgentsAnalyticsSubmissionTable', 29 | new dynamoose.Schema( 30 | { 31 | id: { 32 | type: String, 33 | hashKey: true, 34 | }, 35 | ip: String, 36 | profile: Object, 37 | }, 38 | { 39 | saveUnknown: ['profile.**'], 40 | timestamps: { createdAt: 'timestamp', updatedAt: undefined }, 41 | }, 42 | ), 43 | { create: false, update: false }, 44 | ); 45 | 46 | const getUserAgentTable = async (limit = 1e4) => { 47 | const minimumTimestamp = Date.now() - 1 * 24 * 60 * 60 * 1000; 48 | 49 | // Scan through all recent profiles keeping track of the count of each. 50 | let lastKey = null; 51 | const countsByProfile: { [stringifiedProfile: string]: number } = {}; 52 | const ipAddressAlreadySeen: { [ipAddress: string]: boolean } = {}; 53 | do { 54 | const scan = SubmissionModel.scan( 55 | new dynamoose.Condition().filter('timestamp').gt(minimumTimestamp), 56 | ); 57 | if (lastKey) { 58 | scan.startAt(lastKey); 59 | } 60 | 61 | const response = await scan.exec(); 62 | response.forEach(({ ip, profile }) => { 63 | // Only count one profile per IP address. 64 | if (ipAddressAlreadySeen[ip]) return; 65 | ipAddressAlreadySeen[ip] = true; 66 | 67 | // Filter out bots like Googlebot and YandexBot. 68 | if (isbot(profile.userAgent)) return; 69 | 70 | // Track the counts for this exact profile. 71 | const stringifiedProfile = stableStringify(profile); 72 | if (!countsByProfile[stringifiedProfile]) { 73 | countsByProfile[stringifiedProfile] = 0; 74 | } 75 | countsByProfile[stringifiedProfile] += 1; 76 | }); 77 | 78 | lastKey = response.lastKey; 79 | } while (lastKey); 80 | 81 | // Add some noise to the counts/weights. 82 | const n = () => random.normal(); 83 | Object.entries(countsByProfile).forEach(([stringifiedProfile, count]) => { 84 | const unnormalizedWeight = 85 | Array(2 * count) 86 | .fill(undefined) 87 | .reduce((sum) => sum + n()() ** 2, 0) / 2; 88 | countsByProfile[stringifiedProfile] = unnormalizedWeight; 89 | }); 90 | 91 | // Accumulate the profiles and add/remove a few properties to match the historical format. 92 | const profiles: UserAgentData[] = []; 93 | Object.entries(countsByProfile).forEach(([stringifiedProfile, weight]) => { 94 | if (countsByProfile.hasOwnProperty(stringifiedProfile)) { 95 | const profile = JSON.parse(stringifiedProfile); 96 | profile.weight = weight; 97 | delete profile.sessionId; 98 | 99 | // Find the device category. 100 | const parser = new UAParser(profile.userAgent); 101 | const device = parser.getDevice(); 102 | // Sketchy, but I validated this on historical data and it is a 100% match. 103 | profile.deviceCategory = 104 | { mobile: 'mobile', tablet: 'tablet', undefined: 'desktop' }[`${device.type}`] ?? 'desktop'; 105 | 106 | profiles.push(profile); 107 | delete countsByProfile[stringifiedProfile]; 108 | } 109 | }); 110 | 111 | // Sort by descending weight. 112 | profiles.sort((a, b) => b.weight - a.weight); 113 | 114 | // Apply the count limit and normalize the weights. 115 | profiles.splice(limit); 116 | const totalWeight = profiles.reduce((total, profile) => total + profile.weight, 0); 117 | profiles.forEach((profile) => { 118 | profile.weight /= totalWeight; 119 | }); 120 | 121 | return profiles; 122 | }; 123 | 124 | if (fileURLToPath(import.meta.url) === argv[1]) { 125 | const filename = process.argv[2]; 126 | if (!filename) { 127 | throw new Error('An output filename must be passed as an argument to the command.'); 128 | } 129 | getUserAgentTable() 130 | .then(async (userAgents) => { 131 | const stringifiedUserAgents = JSON.stringify(userAgents, null, 2); 132 | // Compress the content if the extension ends with `.gz`. 133 | const content = filename.endsWith('.gz') 134 | ? gzipSync(stringifiedUserAgents) 135 | : stringifiedUserAgents; 136 | fs.writeFileSync(filename, content); 137 | }) 138 | .catch((error) => { 139 | // eslint-disable-next-line no-console 140 | console.error(error); 141 | process.exit(1); 142 | }); 143 | } 144 | 145 | export default getUserAgentTable; 146 | -------------------------------------------------------------------------------- /src/user-agent.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash.clonedeep'; 2 | 3 | import untypedUserAgents from './user-agents.json' assert { type: 'json' }; 4 | 5 | const userAgents: UserAgentData[] = untypedUserAgents as UserAgentData[]; 6 | 7 | type NestedValueOf = T extends object ? T[keyof T] | NestedValueOf : T; 8 | 9 | export type Filter = UserAgentData> = 10 | | ((parentObject: T) => boolean) 11 | | RegExp 12 | | Array> 13 | | { [key: string]: Filter } 14 | | string; 15 | 16 | export interface UserAgentData { 17 | appName: 'Netscape'; 18 | connection: { 19 | downlink: number; 20 | effectiveType: '3g' | '4g'; 21 | rtt: number; 22 | downlinkMax?: number | null; 23 | type?: 'cellular' | 'wifi'; 24 | }; 25 | language?: string | null; 26 | oscpu?: string | null; 27 | platform: 28 | | 'iPad' 29 | | 'iPhone' 30 | | 'Linux aarch64' 31 | | 'Linux armv81' 32 | | 'Linux armv8l' 33 | | 'Linux x86_64' 34 | | 'MacIntel' 35 | | 'Win32'; 36 | pluginsLength: number; 37 | screenHeight: number; 38 | screenWidth: number; 39 | userAgent: string; 40 | vendor: 'Apple Computer, Inc.' | 'Google Inc.' | ''; 41 | weight: number; 42 | } 43 | 44 | declare module './user-agent' { 45 | export interface UserAgent extends Readonly { 46 | readonly cumulativeWeightIndexPairs: Array<[number, number]>; 47 | readonly data: UserAgentData; 48 | (): UserAgent; 49 | } 50 | } 51 | 52 | // Normalizes the total weight to 1 and constructs a cumulative distribution. 53 | const makeCumulativeWeightIndexPairs = ( 54 | weightIndexPairs: Array<[number, number]>, 55 | ): Array<[number, number]> => { 56 | const totalWeight = weightIndexPairs.reduce((sum, [weight]) => sum + weight, 0); 57 | let sum = 0; 58 | return weightIndexPairs.map(([weight, index]) => { 59 | sum += weight / totalWeight; 60 | return [sum, index]; 61 | }); 62 | }; 63 | 64 | // Precompute these so that we can quickly generate unfiltered user agents. 65 | const defaultWeightIndexPairs: Array<[number, number]> = userAgents.map(({ weight }, index) => [ 66 | weight, 67 | index, 68 | ]); 69 | const defaultCumulativeWeightIndexPairs = makeCumulativeWeightIndexPairs(defaultWeightIndexPairs); 70 | 71 | // Turn the various filter formats into a single filter function that acts on raw user agents. 72 | const constructFilter = >( 73 | filters: Filter, 74 | accessor: (parentObject: T) => T | NestedValueOf = (parentObject: T): T => parentObject, 75 | ): ((profile: T) => boolean) => { 76 | // WARNING: This type and a lot of the types in here are wrong, but I can't get TypeScript to 77 | // resolve things correctly so this will have to do for now. 78 | let childFilters: Array<(parentObject: T) => boolean>; 79 | if (typeof filters === 'function') { 80 | childFilters = [filters]; 81 | } else if (filters instanceof RegExp) { 82 | childFilters = [ 83 | (value: T | NestedValueOf) => 84 | typeof value === 'object' && value && 'userAgent' in value && value.userAgent 85 | ? filters.test(value.userAgent) 86 | : filters.test(value as string), 87 | ]; 88 | } else if (filters instanceof Array) { 89 | childFilters = filters.map((childFilter) => constructFilter(childFilter)); 90 | } else if (typeof filters === 'object') { 91 | childFilters = Object.entries(filters).map(([key, valueFilter]) => 92 | constructFilter( 93 | valueFilter as Filter, 94 | (parentObject: T): T | NestedValueOf => 95 | (parentObject as unknown as { [key: string]: NestedValueOf })[key] as NestedValueOf, 96 | ), 97 | ); 98 | } else { 99 | childFilters = [ 100 | (value: T | NestedValueOf) => 101 | typeof value === 'object' && value && 'userAgent' in value && value.userAgent 102 | ? filters === value.userAgent 103 | : filters === value, 104 | ]; 105 | } 106 | 107 | return (parentObject: T) => { 108 | try { 109 | const value = accessor(parentObject); 110 | return childFilters.every((childFilter) => childFilter(value as T)); 111 | } catch (error) { 112 | // This happens when a user-agent lacks a nested property. 113 | return false; 114 | } 115 | }; 116 | }; 117 | 118 | // Construct normalized cumulative weight index pairs given the filters. 119 | const constructCumulativeWeightIndexPairsFromFilters = ( 120 | filters?: Filter, 121 | ): Array<[number, number]> => { 122 | if (!filters) { 123 | return defaultCumulativeWeightIndexPairs; 124 | } 125 | 126 | const filter = constructFilter(filters); 127 | 128 | const weightIndexPairs: Array<[number, number]> = []; 129 | userAgents.forEach((rawUserAgent, index) => { 130 | if (filter(rawUserAgent)) { 131 | weightIndexPairs.push([rawUserAgent.weight, index]); 132 | } 133 | }); 134 | return makeCumulativeWeightIndexPairs(weightIndexPairs); 135 | }; 136 | 137 | const setCumulativeWeightIndexPairs = ( 138 | userAgent: UserAgent, 139 | cumulativeWeightIndexPairs: Array<[number, number]>, 140 | ) => { 141 | Object.defineProperty(userAgent, 'cumulativeWeightIndexPairs', { 142 | configurable: true, 143 | enumerable: false, 144 | writable: false, 145 | value: cumulativeWeightIndexPairs, 146 | }); 147 | }; 148 | 149 | export class UserAgent extends Function { 150 | constructor(filters?: Filter) { 151 | super(); 152 | setCumulativeWeightIndexPairs(this, constructCumulativeWeightIndexPairsFromFilters(filters)); 153 | if (this.cumulativeWeightIndexPairs.length === 0) { 154 | throw new Error('No user agents matched your filters.'); 155 | } 156 | 157 | this.randomize(); 158 | 159 | // eslint-disable-next-line no-constructor-return 160 | return new Proxy(this, { 161 | apply: () => this.random(), 162 | get: (target, property, receiver) => { 163 | const dataCandidate = 164 | target.data && 165 | typeof property === 'string' && 166 | Object.prototype.hasOwnProperty.call(target.data, property) && 167 | Object.prototype.propertyIsEnumerable.call(target.data, property); 168 | if (dataCandidate) { 169 | const value = target.data[property as keyof UserAgentData]; 170 | if (value !== undefined) { 171 | return value; 172 | } 173 | } 174 | 175 | return Reflect.get(target, property, receiver); 176 | }, 177 | }); 178 | } 179 | 180 | static random = (filters: Filter) => { 181 | try { 182 | return new UserAgent(filters); 183 | } catch (error) { 184 | return null; 185 | } 186 | }; 187 | 188 | // 189 | // Standard Object Methods 190 | // 191 | 192 | [Symbol.toPrimitive] = (): string => this.data.userAgent; 193 | 194 | toString = (): string => this.data.userAgent; 195 | 196 | random = (): UserAgent => { 197 | const userAgent = new UserAgent(); 198 | setCumulativeWeightIndexPairs(userAgent, this.cumulativeWeightIndexPairs); 199 | userAgent.randomize(); 200 | return userAgent; 201 | }; 202 | 203 | randomize = (): void => { 204 | // Find a random raw random user agent. 205 | const randomNumber = Math.random(); 206 | const [, index] = 207 | this.cumulativeWeightIndexPairs.find( 208 | ([cumulativeWeight]) => cumulativeWeight > randomNumber, 209 | ) ?? []; 210 | if (index == null) { 211 | throw new Error('Error finding a random user agent.'); 212 | } 213 | const rawUserAgent = userAgents[index]; 214 | 215 | (this as { data: UserAgentData }).data = cloneDeep(rawUserAgent); 216 | }; 217 | } 218 | -------------------------------------------------------------------------------- /src/user-agents.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intoli/user-agents/80eb33db6104243153637155d0a66cb5d8e0013b/src/user-agents.json.gz -------------------------------------------------------------------------------- /test/test-user-agent.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | 3 | import { UserAgent } from '../src/user-agent.ts'; 4 | 5 | // The randomization tests will be repeated once for each element in the range. 6 | // We should add a more sophisticated RNG with seeding support for additional testing. 7 | const range = Array(1000).fill(); 8 | 9 | describe('UserAgent', () => { 10 | describe('filtering', () => { 11 | it('support object properties', () => { 12 | const userAgent = new UserAgent({ deviceCategory: 'tablet' }); 13 | range.forEach(() => { 14 | assert(userAgent().deviceCategory === 'tablet'); 15 | }); 16 | }); 17 | 18 | it('support nested object properties', () => { 19 | const userAgent = new UserAgent({ connection: { effectiveType: '4g' } }); 20 | range.forEach(() => { 21 | assert(userAgent().connection.effectiveType === '4g'); 22 | }); 23 | }); 24 | 25 | it('support multiple object properties', () => { 26 | const userAgent = new UserAgent({ deviceCategory: 'mobile', pluginsLength: 0 }); 27 | range.forEach(() => { 28 | const { deviceCategory, pluginsLength } = userAgent(); 29 | assert(deviceCategory === 'mobile'); 30 | assert(pluginsLength === 0); 31 | }); 32 | }); 33 | 34 | it('support top-level regular expressions', () => { 35 | const userAgent = new UserAgent(/Safari/); 36 | range.forEach(() => { 37 | assert(/Safari/.test(userAgent())); 38 | }); 39 | }); 40 | 41 | it('support object property regular expressions', () => { 42 | const userAgent = new UserAgent({ userAgent: /Safari/ }); 43 | range.forEach(() => { 44 | assert(/Safari/.test(userAgent())); 45 | }); 46 | }); 47 | 48 | it('support top-level arrays', () => { 49 | const userAgent = new UserAgent([/Android/, /Linux/]); 50 | range.forEach(() => { 51 | const randomUserAgent = userAgent(); 52 | assert(/Android/.test(randomUserAgent) && /Linux/.test(randomUserAgent)); 53 | }); 54 | }); 55 | 56 | it('support object property arrays', () => { 57 | const userAgent = new UserAgent({ deviceCategory: [/(tablet|mobile)/, 'mobile'] }); 58 | range.forEach(() => { 59 | const { deviceCategory } = userAgent(); 60 | assert(deviceCategory === 'mobile'); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('constructor', () => { 66 | it('throw an error when no filters match', () => { 67 | let storedError; 68 | try { 69 | const userAgent = new UserAgent({ deviceCategory: 'fake-no-matches' }); 70 | } catch (error) { 71 | storedError = error; 72 | } 73 | assert(storedError); 74 | }); 75 | }); 76 | 77 | describe('static random()', () => { 78 | it('return null when no filters match', () => { 79 | const userAgent = UserAgent.random({ deviceCategory: 'fake-no-matches' }); 80 | assert(userAgent === null); 81 | }); 82 | 83 | it('return a valid user agent when a filter matches', () => { 84 | const userAgent = UserAgent.random({ userAgent: /Chrome/ }); 85 | assert(userAgent.toString().includes('Chrome')); 86 | assert(/Chrome/.test(userAgent)); 87 | }); 88 | }); 89 | 90 | describe('call handler', () => { 91 | it('produce new user agents that pass the same filters', () => { 92 | const userAgent = UserAgent.random({ userAgent: /Chrome/ }); 93 | range.forEach(() => { 94 | assert(/Chrome/.test(userAgent())); 95 | }); 96 | }); 97 | }); 98 | 99 | describe('cumulativeWeightIndexPairs', () => { 100 | it('have a length greater than 100', () => { 101 | const userAgent = new UserAgent(); 102 | assert(userAgent.cumulativeWeightIndexPairs.length > 100); 103 | }); 104 | 105 | it('have a shorter length when a filter is applied', () => { 106 | const userAgent = new UserAgent(); 107 | const filteredUserAgent = new UserAgent({ deviceCategory: 'mobile' }); 108 | assert( 109 | userAgent.cumulativeWeightIndexPairs.length > 110 | filteredUserAgent.cumulativeWeightIndexPairs.length, 111 | ); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | 4 | "compilerOptions": { 5 | "lib": ["es2020"], 6 | "module": "esnext", 7 | "target": "es2016", 8 | 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noEmit": true, 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "strict": true 17 | }, 18 | "include": [ 19 | "**/*.cts", 20 | "**/*.mts", 21 | "**/*.ts" 22 | ], 23 | "exclude": [ 24 | "node_modules/*", 25 | "dist/*" 26 | ], 27 | "ts-node": { 28 | "transpileOnly": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'esbuild'; 2 | import { defineConfig } from 'tsup'; 3 | 4 | // Tell esbuild not to bundle the JSON file so that we reuse it between CommonJS and ESM builds. 5 | const externalJsonPlugin = (): Plugin => ({ 6 | name: 'external-json', 7 | setup(build) { 8 | build.onResolve({ filter: /user-agents\.json$/ }, (args) => { 9 | return { 10 | path: args.path, 11 | external: true, 12 | }; 13 | }); 14 | }, 15 | }); 16 | 17 | export default defineConfig({ 18 | cjsInterop: true, 19 | dts: true, 20 | entryPoints: ['src/index.ts'], 21 | esbuildPlugins: [externalJsonPlugin()], 22 | format: ['cjs', 'esm'], 23 | minify: true, 24 | outDir: 'dist', 25 | // CJS interop is broken without splitting, see: 26 | // * https://github.com/egoist/tsup/issues/992#issuecomment-1763540165 27 | splitting: true, 28 | sourcemap: true, 29 | target: 'esnext', 30 | }); 31 | --------------------------------------------------------------------------------