├── .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 |
9 |
10 |
12 |
13 |
15 |
16 |
18 |
19 |
20 |
22 |
23 |
25 |
26 |
28 |
29 |
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 |
--------------------------------------------------------------------------------