├── .dockerignore
├── .eslintrc.js
├── .github
└── workflows
│ └── build_docker_image.yml
├── .gitignore
├── .nvmrc
├── .prettierrc.js
├── .vscode
├── launch.json
└── settings.json
├── Dockerfile
├── LICENSE
├── Readme.md
├── img
├── high-trust.png
└── low-trust.png
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── analyze
│ ├── account-age.ts
│ ├── common.ts
│ ├── comp-match-count.ts
│ ├── csgo-collectibles.ts
│ ├── friend-bans.ts
│ ├── game-hours.ts
│ ├── index.ts
│ ├── inventory-value.ts
│ ├── owned-games.ts
│ ├── played-with-bans.ts
│ ├── player-bans.ts
│ ├── rank.ts
│ ├── steam-level.ts
│ └── steamrep.ts
├── charts
│ └── generate.ts
├── common
│ ├── logger.ts
│ ├── types.ts
│ └── util.ts
├── discord
│ ├── bot.ts
│ └── deploy-commands.ts
├── gather
│ ├── csgostats.ts
│ ├── faceit.ts
│ ├── index.ts
│ ├── inventory.ts
│ ├── steamapi.ts
│ └── steamrep.ts
└── index.ts
└── tsconfig.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | img
4 | volume
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true,
5 | },
6 | extends: [
7 | 'airbnb-base',
8 | 'plugin:@typescript-eslint/recommended',
9 | 'plugin:prettier/recommended',
10 | 'plugin:jest/recommended',
11 | ],
12 | parser: '@typescript-eslint/parser',
13 | parserOptions: {
14 | project: './tsconfig.json',
15 | sourceType: 'module',
16 | },
17 | plugins: ['@typescript-eslint', 'jest'],
18 | rules: {
19 | 'import/extensions': 0,
20 | 'import/prefer-default-export': 0,
21 | 'no-shadow': 'off',
22 | '@typescript-eslint/no-shadow': ['error'],
23 | 'camelcase': 'off',
24 | },
25 | settings: {
26 | 'import/extensions': ['.js', '.ts'],
27 | 'import/parsers': {
28 | '@typescript-eslint/parser': ['.ts'],
29 | },
30 | 'import/resolver': {
31 | node: {
32 | extensions: ['.js', '.ts'],
33 | },
34 | },
35 | },
36 | ignorePatterns: ['node_modules', 'dist', '**/*.d.ts'],
37 | };
38 |
--------------------------------------------------------------------------------
/.github/workflows/build_docker_image.yml:
--------------------------------------------------------------------------------
1 | name: MultiArchDockerBuild
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build_multi_arch_image:
10 | name: Build multi-arch Docker image.
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v2
15 |
16 | - name: Set up QEMU
17 | uses: docker/setup-qemu-action@v1
18 |
19 | - name: Set up Docker Buildx
20 | id: buildx
21 | uses: docker/setup-buildx-action@v1
22 | with:
23 | install: true
24 |
25 | - name: Docker meta
26 | id: meta
27 | uses: docker/metadata-action@v3
28 | with:
29 | images: ghcr.io/${{ github.repository }}
30 | tags: latest
31 |
32 | - name: Login to GitHub Container Registry
33 | uses: docker/login-action@v1
34 | with:
35 | registry: ghcr.io
36 | username: ${{ github.repository_owner }}
37 | password: ${{ secrets.GITHUB_TOKEN }}
38 |
39 | - name: Build and push
40 | uses: docker/build-push-action@v2
41 | with:
42 | push: true
43 | tags: ${{ steps.meta.outputs.tags }}
44 | platforms: linux/amd64
45 | build-args: |
46 | COMMIT_SHA=${{ github.sha }}
47 | cache-from: type=gha,scope=${{ github.workflow }}
48 | cache-to: type=gha,mode=max,scope=${{ github.workflow }}
49 |
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | docs
2 | _output*
3 | _*Output*
4 | volume
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # TypeScript v1 declaration files
50 | typings/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 | .env.test
79 |
80 | # parcel-bundler cache (https://parceljs.org/)
81 | .cache
82 |
83 | # Next.js build output
84 | .next
85 |
86 | # Nuxt.js build / generate output
87 | .nuxt
88 | dist
89 |
90 | # Gatsby files
91 | .cache/
92 | # Comment in the public line in if your project uses Gatsby and not Next.js
93 | # https://nextjs.org/blog/next-9-1#public-directory-support
94 | # public
95 |
96 | # vuepress build output
97 | .vuepress/dist
98 |
99 | # Serverless directories
100 | .serverless/
101 |
102 | # FuseBox cache
103 | .fusebox/
104 |
105 | # DynamoDB Local files
106 | .dynamodb/
107 |
108 | # TernJS port file
109 | .tern-port
110 |
111 | # Stores VSCode versions used for testing VSCode extensions
112 | .vscode-test
113 |
114 | config/*
115 | test-cookies.json
116 | test.png
117 |
118 | .idea
119 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 100,
3 | singleQuote: true,
4 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Debug Jest Tests",
9 | "type": "node",
10 | "request": "launch",
11 | "runtimeArgs": [
12 | "--inspect-brk",
13 | "${workspaceRoot}/node_modules/.bin/jest",
14 | "--runInBand"
15 | ],
16 | "console": "integratedTerminal",
17 | "internalConsoleOptions": "neverOpen"
18 | },
19 | {
20 | "type": "node",
21 | "request": "launch",
22 | "name": "Debug Main",
23 | "runtimeArgs": [
24 | "-r",
25 | "ts-node/register"
26 | ],
27 | "args": [
28 | "${workspaceFolder}/src/index.ts"
29 | ],
30 | "outputCapture": "std"
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.validate": [
3 | "javascript",
4 | "typescript"
5 | ],
6 | "editor.codeActionsOnSave": {
7 | "source.fixAll.eslint": true
8 | },
9 | "editor.formatOnSave": true,
10 | "[javascript]": {
11 | "editor.formatOnSave": false,
12 | },
13 | "[typescript]": {
14 | "editor.formatOnSave": false,
15 | },
16 | "[json]": {
17 | "files.insertFinalNewline": true
18 | },
19 | "typescript.tsdk": "node_modules/typescript/lib",
20 | "sqltools.connections": [
21 | {
22 | "previewLimit": 50,
23 | "driver": "SQLite",
24 | "name": "Local SQLite",
25 | "database": "./volume/csgo-sus-cache.sqlite"
26 | }
27 | ],
28 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ########
2 | # BASE
3 | ########
4 | FROM node:16-bullseye-slim as base
5 |
6 | WORKDIR /usr/app
7 |
8 | ########
9 | # DEPS
10 | ########
11 | FROM base as deps
12 |
13 | RUN echo "deb http://deb.debian.org/debian bullseye main contrib non-free" > /etc/apt/sources.list \
14 | && echo "deb http://deb.debian.org/debian-security/ bullseye-security main contrib non-free" >> /etc/apt/sources.list \
15 | && echo "deb http://deb.debian.org/debian bullseye-updates main contrib non-free" >> /etc/apt/sources.list \
16 | && echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections \
17 | && apt-get update \
18 | && apt-get install -y \
19 | # fonts
20 | fonts-arphic-ukai \
21 | fonts-arphic-uming \
22 | fonts-ipafont-mincho \
23 | fonts-thai-tlwg \
24 | fonts-kacst \
25 | fonts-ipafont-gothic \
26 | fonts-unfonts-core \
27 | ttf-wqy-zenhei \
28 | ttf-mscorefonts-installer \
29 | fonts-freefont-ttf \
30 | # app
31 | tini \
32 | && apt-get clean \
33 | && apt-get autoremove -y \
34 | && rm -rf /var/lib/apt/lists/*
35 |
36 | # Copy package.json for version number
37 | COPY package*.json ./
38 |
39 | RUN npm ci --only=production && $(npx install-browser-deps) \
40 | # Heavy inspiration from: https://github.com/ulixee/hero/blob/main/Dockerfile
41 | && groupadd -r csgosus \
42 | && useradd -r -g csgosus -G audio,video csgosus \
43 | && mkdir -p /home/csgosus/Downloads \
44 | && mkdir -p /home/csgosus/.cache \
45 | && chown -R csgosus:csgosus /home/csgosus \
46 | && mv ~/.cache/ulixee /home/csgosus/.cache/ \
47 | && chmod 777 /tmp
48 | # && chmod -R 777 /home/csgosus/.cache/ulixee
49 |
50 | ########
51 | # BUILD
52 | ########
53 | FROM base as build
54 |
55 | # Copy all source files
56 | COPY package*.json tsconfig.json ./
57 |
58 | # Add dev deps
59 | RUN npm ci
60 |
61 | # Copy source code
62 | COPY src src
63 |
64 | RUN npm run build
65 |
66 |
67 | ########
68 | # DEPLOY
69 | ########
70 | FROM deps as deploy
71 |
72 | # Add below to run as unprivileged user.
73 | USER csgosus
74 |
75 | # Steal compiled code from build image
76 | COPY --from=build /usr/app/dist ./dist
77 |
78 | LABEL org.opencontainers.image.title="csgo-sus" \
79 | org.opencontainers.image.url="https://github.com/claabs/csgo-sus" \
80 | org.opencontainers.image.description="Lookup in-depth public data on CSGO players' accounts to see if they're suspicious" \
81 | org.opencontainers.image.name="csgo-sus" \
82 | org.opencontainers.image.base.name="node:16-bullseye-slim"
83 |
84 | ARG COMMIT_SHA=""
85 |
86 | ENV NODE_ENV=production \
87 | CACHE_DIR=/csgo-sus \
88 | COMMIT_SHA=${COMMIT_SHA}
89 |
90 | VOLUME [ "/csgo-sus" ]
91 |
92 | ENTRYPOINT ["tini", "--"]
93 |
94 | CMD ["node", "dist/index.js"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published by
637 | the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # csgo-sus
2 |
3 | Lookup in-depth public data on CSGO players' accounts to see if they're suspicious
4 |
5 | ## Metrics
6 |
7 | - [x] Account age
8 | - [x] Steam level
9 | - [x] Inventory value
10 | - [x] Number of owned games
11 | - [x] CSGO badges
12 | - [x] Derank
13 | - [x] Hours in CS
14 | - [x] Number of competitive matches won
15 | - [x] Player bans
16 | - [x] Friend bans
17 | - [x] Friend bans
18 | - [x] Cheaters played with regularly
19 | - [x] Steamrep
20 | - [ ] Smurf finder
21 | - [ ] Recent performance consistency
22 | - [ ] Unusual statistics (HS%)
23 | - [ ] Faceit
24 | - [ ] Esportal
25 | - [ ] SourceBans
26 | - [ ] RustBanned
27 | - [ ] ServerArmour
28 |
29 | ## Usage
30 |
31 | 1. Get the following API keys/tokens
32 | - [FaceIt](https://developers.faceit.com/)
33 | - [Steam](https://steamcommunity.com/dev/apikey)
34 | - [Discord](https://discord.com/developers/applications)
35 | 1. Deploy with Docker: `docker run --security-opt seccomp=unconfined -e STEAM_API_KEY=acb123 -e FACEIT_API_KEY=abc123 -e DISCORD_BOT_TOKEN=abc123 -v /my/mount/point:/csgo-sus ghcr.io/claabs/csgo-sus`
36 | - Technically, `seccomp=unconfined` is bad security practice, so instead you can use `seccomp="/path/to/seccomp_profile.json` with [this JSON file](https://github.com/ulixee/secret-agent/blob/main/tools/docker/seccomp_profile.json). This requirement comes from [here](https://github.com/ulixee/secret-agent/blob/main/tools/docker/run-core-server.sh)
37 | 1. Enable the "Message Content Intent" in the bot settings and add to your server using the link logged on startup
38 |
39 | ### Discord Bot
40 |
41 | During algorithm development, a Discord bot is being used in lieu of a UI. Use `/help` to get instructions on how to use it
42 |
43 | #### Example Low Trust Player
44 |
45 | 
46 |
47 | #### Example High Trust Player
48 |
49 | 
50 |
51 | ### Web UI
52 |
53 | Coming soon...
54 |
--------------------------------------------------------------------------------
/img/high-trust.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/claabs/csgo-sus/dae4da0b97586c3a92538e6e8d8342e49035b4be/img/high-trust.png
--------------------------------------------------------------------------------
/img/low-trust.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/claabs/csgo-sus/dae4da0b97586c3a92538e6e8d8342e49035b4be/img/low-trust.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | testMatch: ['**/*.spec.ts'],
5 | testPathIgnorePatterns: ['/node_modules/', '/dist/'],
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "csgos.us",
3 | "version": "0.1.0",
4 | "description": "Lookup in-depth public data on CSGO players' accounts to see if they're suspicious",
5 | "scripts": {
6 | "test": "jest --coverage",
7 | "build": "rimraf dist && tsc",
8 | "lint": "tsc --noEmit && eslint src/**/*.ts",
9 | "start": "ts-node src/index.ts",
10 | "start:debug": "SA_SHOW_BROWSER=true ts-node src/index.ts",
11 | "charts": "ts-node src/charts/generate.ts",
12 | "docker:build": "docker build . -t ghcr.io/claabs/csgo-sus:latest --target deploy",
13 | "docker:run": "mkdir -p volume && chmod 777 volume && docker run --rm -ti --security-opt seccomp=unconfined -v $(pwd)/volume:/csgo-sus --env-file .env -e CACHE_DIR=/csgo-sus ghcr.io/claabs/csgo-sus:latest"
14 | },
15 | "keywords": [
16 | "csgostats.gg",
17 | "csgo",
18 | "opponent",
19 | "player",
20 | "faceitfinder"
21 | ],
22 | "repository": {
23 | "type": "git",
24 | "url": "https://github.com/claabs/csgo-sus.git"
25 | },
26 | "homepage": "https://csgos.us",
27 | "author": {
28 | "name": "Charlie Laabs",
29 | "url": "https://github.com/claabs"
30 | },
31 | "license": "AGPL-3.0-only",
32 | "dependencies": {
33 | "@discordjs/builders": "^0.13.0",
34 | "@discordjs/rest": "^0.4.1",
35 | "@keyv/sqlite": "^3.5.2",
36 | "@stdlib/stats-incr-wmean": "^0.0.7",
37 | "@ulixee/hero-core": "^2.0.0-alpha.6",
38 | "axios": "^0.27.2",
39 | "csgostatsgg-scraper": "^1.5.0",
40 | "discord-api-types": "^0.33.0",
41 | "discord.js": "^13.7.0",
42 | "dotenv": "^16.0.1",
43 | "fs-extra": "^10.1.0",
44 | "keyv": "^4.3.0",
45 | "moment": "^2.29.3",
46 | "pino": "^7.11.0",
47 | "pino-pretty": "^7.6.1",
48 | "source-map-support": "^0.5.21",
49 | "steamapi": "^2.2.0",
50 | "steamcommunity-inventory": "^2.0.5",
51 | "steamid": "^2.0.0",
52 | "tinygradient": "^1.1.5"
53 | },
54 | "devDependencies": {
55 | "@types/fs-extra": "^9.0.13",
56 | "@types/jest": "^27.5.1",
57 | "@types/node": "^16.11.36",
58 | "@types/steamapi": "^2.2.2",
59 | "@types/steamid": "^2.0.1",
60 | "@typescript-eslint/eslint-plugin": "^5.26.0",
61 | "@typescript-eslint/parser": "^5.26.0",
62 | "eslint": "^8.16.0",
63 | "eslint-config-airbnb-base": "^15.0.0",
64 | "eslint-config-prettier": "^8.5.0",
65 | "eslint-plugin-import": "^2.26.0",
66 | "eslint-plugin-jest": "^26.2.2",
67 | "eslint-plugin-prettier": "^4.0.0",
68 | "jest": "^28.1.0",
69 | "nodeplotlib": "^0.7.6",
70 | "prettier": "^2.6.2",
71 | "ts-jest": "^28.0.3",
72 | "ts-node": "^10.8.0",
73 | "types-package-json": "^2.0.39",
74 | "typescript": "^4.7.2"
75 | },
76 | "engines": {
77 | "node": "16"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/analyze/account-age.ts:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { ScorePlotData } from '../common/types';
3 | import { range } from '../common/util';
4 | import { PlayerData } from '../gather';
5 | import { Analysis } from './common';
6 |
7 | export interface AccountAgeAnalysis extends Analysis {
8 | accountCreatedAgo: string;
9 | accountCreatedDate: string;
10 | }
11 |
12 | const SCORE_RANGE = 100;
13 | const OFFSET = -10; // Recent 10% of time gets negative
14 | const STEAM_CREATE_DATE = moment('2003-09-12');
15 |
16 | const scoreFunction = (ratioOfSteamExistence: number): number => {
17 | return ratioOfSteamExistence * SCORE_RANGE + OFFSET;
18 | };
19 |
20 | export const analyzeAccountAge = (player: PlayerData): AccountAgeAnalysis => {
21 | const momentCreated = moment(player.createdAt);
22 | const daysAccountExisted = moment().diff(momentCreated, 'days');
23 | const daysSteamExisted = moment().diff(STEAM_CREATE_DATE, 'days');
24 | const accountCreatedAgo = momentCreated.fromNow();
25 | const accountCreatedDate = momentCreated.format(moment.HTML5_FMT.DATE);
26 | const score = scoreFunction(daysAccountExisted / daysSteamExisted);
27 | return {
28 | accountCreatedAgo,
29 | accountCreatedDate,
30 | score,
31 | };
32 | };
33 |
34 | const plotData = (): ScorePlotData => {
35 | const daysSteamExisted = moment().diff(STEAM_CREATE_DATE, 'days');
36 | const x = range(0, daysSteamExisted);
37 | const y = x.map((xVal) => scoreFunction(xVal / daysSteamExisted));
38 | return {
39 | title: 'Account Age',
40 | x,
41 | xAxisLabel: 'Account Age (days)',
42 | y,
43 | };
44 | };
45 | export const accountAgePlot: ScorePlotData[] = [plotData()];
46 |
--------------------------------------------------------------------------------
/src/analyze/common.ts:
--------------------------------------------------------------------------------
1 | export interface Analysis {
2 | score: number;
3 | link?: string;
4 | tags?: string[];
5 | }
6 |
--------------------------------------------------------------------------------
/src/analyze/comp-match-count.ts:
--------------------------------------------------------------------------------
1 | import { ScorePlotData } from '../common/types';
2 | import { range } from '../common/util';
3 | import { PlayerData } from '../gather';
4 | import { Analysis } from './common';
5 |
6 | export interface CompMatchWinsAnalysis extends Analysis {
7 | count?: number;
8 | }
9 |
10 | const MATCH_SCORE_MULTIPLIER = 0.1; // 10 points for 100 wins
11 | const MAXIMUM_WINS = 100;
12 | const OFFSET = -3; // 30 wins gets 0
13 | const NEGATIVE_SCORE_MULTIPLIER = 10;
14 | const NO_DATA_SCORE = -2;
15 |
16 | const scoreFunction = (numWins: number): number => {
17 | let score: number;
18 | score = Math.min(numWins, MAXIMUM_WINS) * MATCH_SCORE_MULTIPLIER + OFFSET;
19 | if (score < 0) score *= NEGATIVE_SCORE_MULTIPLIER; // Don't give a large bonus for high wins, but big impact if now wins
20 | return score;
21 | };
22 |
23 | export const analyzeCompMatchWins = (player: PlayerData): CompMatchWinsAnalysis => {
24 | const { csgoStatsPlayer } = player;
25 | const count = csgoStatsPlayer?.summary.competitiveWins;
26 | let score: number;
27 | const link = `https://csgostats.gg/player/${player.steamId.getSteamID64()}?type=comp`;
28 | if (count) {
29 | score = scoreFunction(count);
30 | } else {
31 | score = NO_DATA_SCORE;
32 | }
33 | return {
34 | count,
35 | score,
36 | link,
37 | };
38 | };
39 |
40 | const plotData = (): ScorePlotData => {
41 | const x = range(0, 150);
42 | const y = x.map((xVal) => scoreFunction(xVal));
43 | return {
44 | title: 'Competitive Wins',
45 | x,
46 | xAxisLabel: 'Competitive Wins',
47 | y,
48 | };
49 | };
50 | export const compMatchWinsPlot: ScorePlotData[] = [plotData()];
51 |
--------------------------------------------------------------------------------
/src/analyze/csgo-collectibles.ts:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { EconItem } from 'steamcommunity-inventory';
3 | import { PlayerData } from '../gather';
4 | import { Analysis } from './common';
5 |
6 | export interface CSGOCollectiblesAnalysis extends Analysis {
7 | csgoCollectiblesCount?: number;
8 | oldestCSGOCollectibleAgo?: string;
9 | oldestCSGOCollectibleDate?: string;
10 | }
11 |
12 | export interface EconItemWithDate extends EconItem {
13 | date?: moment.Moment;
14 | }
15 |
16 | const PRIVATE_PROFILE_SCORE = -2;
17 | // Numbner of badges
18 | const NUMBER_BADGES_MULTIPLIER = 4;
19 | const NUMBER_BADGES_OFFSET = -12; // 3 badges is 0
20 |
21 | // Badge age
22 | const COLLECTIBLE_AGE_SCORE_RANGE = 50;
23 | const COLLECTIBLE_AGE_SCORE_OFFSET = -1 * (COLLECTIBLE_AGE_SCORE_RANGE / 10); // Recent 10% of time gets negative
24 | const OLDEST_COLLECTIBLE_DATE = moment('2013-04-26'); // Operation payback start date. Is this the first?
25 |
26 | export const analyzeCSGOCollectibles = (player: PlayerData): CSGOCollectiblesAnalysis => {
27 | const collectibles = player.inventory?.collectibles;
28 | let csgoCollectiblesCount: number | undefined;
29 | let oldestCSGOCollectibleAgo: string | undefined;
30 | let oldestCSGOCollectibleDate: string | undefined;
31 | let collectibleCountScore: number | undefined;
32 | let collectibleAgeScore: number | undefined;
33 | let score: number;
34 | if (collectibles?.length) {
35 | csgoCollectiblesCount = collectibles.length;
36 | const collectiblesWithDate: EconItemWithDate[] = collectibles.map((collectible) => {
37 | const ageDesc = collectible.descriptions.find((desc) => desc.value.includes('Date'));
38 | if (ageDesc) {
39 | const match = ageDesc.value.match(/(Date of Issue|Deployment Date): (.*)/);
40 | const dateString = match?.[2];
41 | if (dateString) {
42 | const issueDate = moment(dateString, 'MMM DD, YYYY');
43 | return {
44 | ...collectible,
45 | date: issueDate,
46 | };
47 | }
48 | }
49 | return collectible;
50 | });
51 | const oldestCollectible: EconItemWithDate = collectiblesWithDate.reduce((acc, curr) => {
52 | if (!curr.date) return acc;
53 | if (!acc.date) return curr;
54 | if (curr.date.isBefore(acc.date)) return curr;
55 | return acc;
56 | });
57 | collectibleAgeScore = 0;
58 | if (oldestCollectible?.date) {
59 | oldestCSGOCollectibleAgo = oldestCollectible.date.fromNow();
60 | oldestCSGOCollectibleDate = oldestCollectible.date.format(moment.HTML5_FMT.DATE);
61 | const daysSinceOldestCollectible = oldestCollectible.date.diff(moment.now(), 'days');
62 | const daysCollectiblesExisted = OLDEST_COLLECTIBLE_DATE.diff(moment.now(), 'days');
63 | collectibleAgeScore =
64 | (daysSinceOldestCollectible / daysCollectiblesExisted) * COLLECTIBLE_AGE_SCORE_RANGE +
65 | COLLECTIBLE_AGE_SCORE_OFFSET;
66 | }
67 | collectibleCountScore = csgoCollectiblesCount * NUMBER_BADGES_MULTIPLIER + NUMBER_BADGES_OFFSET;
68 | score = collectibleAgeScore + collectibleCountScore;
69 | } else {
70 | score = PRIVATE_PROFILE_SCORE;
71 | }
72 | return {
73 | csgoCollectiblesCount,
74 | oldestCSGOCollectibleAgo,
75 | oldestCSGOCollectibleDate,
76 | score,
77 | };
78 | };
79 |
--------------------------------------------------------------------------------
/src/analyze/friend-bans.ts:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { ScorePlotData } from '../common/types';
3 | import { range } from '../common/util';
4 | import { PlayerData } from '../gather';
5 | import { Analysis } from './common';
6 | import L from '../common/logger';
7 |
8 | export interface FriendBansAnalysis extends Analysis {
9 | friendsBannedWhenFriends?: number;
10 | friendsBannedBeforeFriends?: number;
11 | }
12 |
13 | const FRIENDS_BANNED_WHEN_FRIENDS_MULTIPLIER = -1;
14 | const FRIENDS_BANNED_BEFORE_FRIENDS_MULTIPLIER = -0.5;
15 |
16 | const friendsBannedWhenFriendsScoreFunction = (count: number): number => {
17 | return count * FRIENDS_BANNED_WHEN_FRIENDS_MULTIPLIER;
18 | };
19 | const friendsBannedBeforeFriendsScoreFunction = (count: number): number => {
20 | return count * FRIENDS_BANNED_BEFORE_FRIENDS_MULTIPLIER;
21 | };
22 |
23 | export const analyzeFriendBans = (player: PlayerData): FriendBansAnalysis => {
24 | const { friends } = player;
25 | let score = 0;
26 | const link = `https://csgostats.gg/player/${player.steamId.getSteamID64()}?type=comp#/players`;
27 | let friendsBannedWhenFriends;
28 | let friendsBannedBeforeFriends;
29 | if (friends) {
30 | const bannedFriends = friends
31 | .filter(
32 | (friend) =>
33 | friend.communityBanned ||
34 | friend.economyBan !== 'none' ||
35 | friend.vacBanned ||
36 | friend.gameBans > 0
37 | )
38 | .map((friend) => {
39 | const friendedAt = moment(friend.friendSince);
40 | const lastBanAt = moment().subtract(friend.daysSinceLastBan, 'days');
41 | let playedWithTimes: number | undefined;
42 |
43 | const playedWithData = player.csgoStatsDeepPlayedWith?.root.find(
44 | (pw) => pw.steam_id === friend.steamID
45 | );
46 | if (playedWithData) {
47 | playedWithTimes = playedWithData.stats.games;
48 | }
49 | const friendsWhenBanned = friendedAt.isBefore(lastBanAt);
50 | return {
51 | playedWithTimes,
52 | friendsWhenBanned,
53 | profileLink: friend.url,
54 | id: friend.steamID,
55 | };
56 | });
57 |
58 | const friendsBannedWhenFriendsList = bannedFriends.filter((f) => f.friendsWhenBanned);
59 | friendsBannedWhenFriends = friendsBannedWhenFriendsList.length || undefined;
60 | if (friendsBannedWhenFriends) L.debug({ friendsBannedWhenFriendsList });
61 |
62 | const friendsBannedBeforeFriendsList = bannedFriends.filter((f) => !f.friendsWhenBanned);
63 | friendsBannedBeforeFriends = friendsBannedBeforeFriendsList.length || undefined;
64 | if (friendsBannedBeforeFriends) L.debug({ friendsBannedBeforeFriendsList });
65 | }
66 |
67 | if (friendsBannedWhenFriends) {
68 | score += friendsBannedWhenFriendsScoreFunction(friendsBannedWhenFriends);
69 | }
70 | if (friendsBannedBeforeFriends) {
71 | score += friendsBannedBeforeFriendsScoreFunction(friendsBannedBeforeFriends);
72 | }
73 |
74 | score = Math.min(0, score); // Don't get positive points
75 | return {
76 | friendsBannedWhenFriends,
77 | friendsBannedBeforeFriends,
78 | score,
79 | link,
80 | };
81 | };
82 |
83 | const plotFriendsBannedWhenFriendsRateData = (): ScorePlotData => {
84 | const x = range(0, 100);
85 | const y = x.map((xVal) => friendsBannedWhenFriendsScoreFunction(xVal));
86 | return {
87 | title: 'Number of friends banned during friendship',
88 | x,
89 | xAxisLabel: 'Number of friends',
90 | y,
91 | };
92 | };
93 | const plotFriendsBannedBeforeFriendsRateData = (): ScorePlotData => {
94 | const x = range(0, 100);
95 | const y = x.map((xVal) => friendsBannedBeforeFriendsScoreFunction(xVal));
96 | return {
97 | title: 'Number of friends banned before friendship',
98 | x,
99 | xAxisLabel: 'Number of friends',
100 | y,
101 | };
102 | };
103 | export const friendBansPlot: ScorePlotData[] = [
104 | plotFriendsBannedWhenFriendsRateData(),
105 | plotFriendsBannedBeforeFriendsRateData(),
106 | ];
107 |
--------------------------------------------------------------------------------
/src/analyze/game-hours.ts:
--------------------------------------------------------------------------------
1 | import { ScorePlotData } from '../common/types';
2 | import { range } from '../common/util';
3 | import { PlayerData } from '../gather';
4 | import { Analysis } from './common';
5 |
6 | export interface GameHoursAnalysis extends Analysis {
7 | total?: number;
8 | last2Weeks?: number;
9 | }
10 |
11 | const HOUR_SCORE_MULTIPLIER = 0.01; // 10 points for 1000 hour
12 | const MAXIMUM_HOURS = 1000;
13 | const OFFSET = -1; // 100 hours gets 0
14 | const NEGATIVE_SCORE_MULTIPLIER = 10;
15 | const PRIVATE_PROFILE_SCORE = -2;
16 |
17 | const scoreFunction = (totalHours: number): number => {
18 | let score: number;
19 | score = Math.min(totalHours, MAXIMUM_HOURS) * HOUR_SCORE_MULTIPLIER + OFFSET;
20 | if (score < 0) score *= NEGATIVE_SCORE_MULTIPLIER; // Don't give a large bonus for high hours, but big impact if now hours
21 | return score;
22 | };
23 |
24 | export const analyzeGameHours = (player: PlayerData): GameHoursAnalysis => {
25 | const { ownedGames, recentGames } = player;
26 | const gameDetails =
27 | ownedGames?.find((game) => game.appID === 730) ||
28 | recentGames?.find((game) => game.appID === 730);
29 | let total: number | undefined;
30 | let last2Weeks: number | undefined;
31 | let score: number;
32 | if (gameDetails && gameDetails.playTime > 0) {
33 | total = gameDetails.playTime / 60;
34 | last2Weeks = gameDetails.playTime2 / 60;
35 | score = scoreFunction(total);
36 | } else {
37 | score = PRIVATE_PROFILE_SCORE;
38 | }
39 | return {
40 | total,
41 | last2Weeks,
42 | score,
43 | };
44 | };
45 |
46 | const plotData = (): ScorePlotData => {
47 | const x = range(0, 1100);
48 | const y = x.map((xVal) => scoreFunction(xVal));
49 | return {
50 | title: 'CS:GO Game Hours',
51 | x,
52 | xAxisLabel: 'Total Hours In-Game',
53 | y,
54 | };
55 | };
56 | export const gameHoursPlot: ScorePlotData[] = [plotData()];
57 |
--------------------------------------------------------------------------------
/src/analyze/index.ts:
--------------------------------------------------------------------------------
1 | import SteamID from 'steamid';
2 | import { PlayerData } from '../gather';
3 | import { analyzeAccountAge, AccountAgeAnalysis } from './account-age';
4 | import { analyzeSteamLevel, SteamLevelAnalysis } from './steam-level';
5 | import { analyzeInventoryValue, InventoryValueAnalysis } from './inventory-value';
6 | import { analyzeOwnedGames, OwnedGamesAnalysis } from './owned-games';
7 | import { analyzeCSGOCollectibles, CSGOCollectiblesAnalysis } from './csgo-collectibles';
8 | import { analyzeRank, RankAnalysis } from './rank';
9 | import { analyzeGameHours, GameHoursAnalysis } from './game-hours';
10 | import { analyzeCompMatchWins, CompMatchWinsAnalysis } from './comp-match-count';
11 | import { analyzePlayerBans, PlayerBansAnalysis } from './player-bans';
12 | import { analyzeFriendBans, FriendBansAnalysis } from './friend-bans';
13 | import { analyzePlayedWithBans, PlayedWithBansAnalysis } from './played-with-bans';
14 | import { analyzeSteamRep, SteamRepAnalysis } from './steamrep';
15 |
16 | /**
17 | * Account age
18 | * Steam level
19 | * Inventory value
20 | * Number of owned games
21 | * CSGO badges
22 | * Derank
23 | * Hours in CS
24 | * Number of competitive matches won
25 | * Player bans
26 | * Friend bans
27 | * Cheaters played with regularly
28 | * Smurf finder
29 | * Recent performance consistency
30 | * Unusual statistics (HS%)
31 | * Steamrep
32 | * Faceit
33 | */
34 |
35 | export interface AnalysisSummary {
36 | accountAge: AccountAgeAnalysis;
37 | steamLevel: SteamLevelAnalysis;
38 | inventoryValue: InventoryValueAnalysis;
39 | ownedGames: OwnedGamesAnalysis;
40 | csgoCollectibles: CSGOCollectiblesAnalysis;
41 | rank: RankAnalysis;
42 | gameHours: GameHoursAnalysis;
43 | competitiveWins: CompMatchWinsAnalysis;
44 | playerBans: PlayerBansAnalysis;
45 | friendBans: FriendBansAnalysis;
46 | playedWithBans: PlayedWithBansAnalysis;
47 | steamRep: SteamRepAnalysis;
48 | }
49 |
50 | export type AnalysesEntry = [keyof AnalysisSummary, AnalysisSummary[keyof AnalysisSummary]];
51 |
52 | export interface PlayerAnalysis {
53 | nickname?: string;
54 | profileLink?: string;
55 | profileImage?: string;
56 | steamId: SteamID;
57 | analyses: AnalysisSummary;
58 | positiveAnalyses?: AnalysesEntry[];
59 | negativeAnalyses?: AnalysesEntry[];
60 | totalScore: number;
61 | }
62 |
63 | export const analyzePlayer = (player: PlayerData): PlayerAnalysis => {
64 | const analyses: AnalysisSummary = {
65 | accountAge: analyzeAccountAge(player),
66 | steamLevel: analyzeSteamLevel(player),
67 | inventoryValue: analyzeInventoryValue(player),
68 | ownedGames: analyzeOwnedGames(player),
69 | csgoCollectibles: analyzeCSGOCollectibles(player),
70 | rank: analyzeRank(player),
71 | gameHours: analyzeGameHours(player),
72 | competitiveWins: analyzeCompMatchWins(player),
73 | playerBans: analyzePlayerBans(player),
74 | friendBans: analyzeFriendBans(player),
75 | playedWithBans: analyzePlayedWithBans(player),
76 | steamRep: analyzeSteamRep(player),
77 | };
78 | const totalScore = Object.values(analyses).reduce((acc, curr) => acc + curr.score, 0);
79 | const positiveAnalyses = Object.entries(analyses)
80 | .filter(([, val]) => val.score >= 0)
81 | .sort(([, val1], [, val2]) => val2.score - val1.score) as AnalysesEntry[];
82 | const negativeAnalyses = Object.entries(analyses)
83 | .filter(([, val]) => val.score < 0)
84 | .sort(([, val1], [, val2]) => val1.score - val2.score) as AnalysesEntry[];
85 | return {
86 | nickname: player.summary?.nickname,
87 | profileLink: player.summary?.url,
88 | profileImage: player.summary?.avatar.medium,
89 | steamId: player.steamId,
90 | analyses,
91 | positiveAnalyses,
92 | negativeAnalyses,
93 | totalScore,
94 | };
95 | };
96 |
97 | export const analyzePlayers = (players: PlayerData[]): PlayerAnalysis[] => {
98 | return players.map((player) => analyzePlayer(player));
99 | };
100 |
--------------------------------------------------------------------------------
/src/analyze/inventory-value.ts:
--------------------------------------------------------------------------------
1 | import { PlayerData } from '../gather';
2 | import { Analysis } from './common';
3 |
4 | export interface InventoryValueAnalysis extends Analysis {
5 | fixedInventoryValue?: string;
6 | marketableItemsCount?: number;
7 | }
8 |
9 | const VALUE_SCORE_MULTIPLIER = 0.15; // 150 points for $1000+
10 | const MAXIMUM_VALUE = 1000;
11 | const OFFSET = -4.5; // $30 account gets 0
12 | const PRIVATE_PROFILE_SCORE = -2;
13 |
14 | export const analyzeInventoryValue = (player: PlayerData): InventoryValueAnalysis => {
15 | const items = player.inventory?.marketableItems;
16 | let fixedInventoryValue: string | undefined;
17 | let marketableItemsCount: number | undefined;
18 | let score: number;
19 | const link = player.summary?.url && items ? `${player.summary?.url}inventory/#730` : undefined;
20 | if (items?.length) {
21 | const valueDollars = items.reduce((accum, item) => {
22 | const itemPrice = item.price || 0;
23 | return accum + itemPrice;
24 | }, 0);
25 | marketableItemsCount = items.length;
26 | const cappedValueDollars = valueDollars >= MAXIMUM_VALUE ? MAXIMUM_VALUE : valueDollars; // Cap at maximum value
27 | score = cappedValueDollars * VALUE_SCORE_MULTIPLIER + OFFSET;
28 | fixedInventoryValue = `$${valueDollars.toFixed(2)}`;
29 | } else {
30 | score = PRIVATE_PROFILE_SCORE;
31 | }
32 | return {
33 | fixedInventoryValue,
34 | marketableItemsCount,
35 | score,
36 | link,
37 | };
38 | };
39 |
--------------------------------------------------------------------------------
/src/analyze/owned-games.ts:
--------------------------------------------------------------------------------
1 | import { PlayerData } from '../gather';
2 | import { Analysis } from './common';
3 |
4 | export interface OwnedGamesAnalysis extends Analysis {
5 | ownedGames?: number;
6 | }
7 |
8 | const VALUE_SCORE_MULTIPLIER = 1; // 100 points for 100 games
9 | const MAXIMUM_GAMES = 100;
10 | const OFFSET = -4; // 4 games gets 0
11 | const PRIVATE_PROFILE_SCORE = -2;
12 |
13 | export const analyzeOwnedGames = (player: PlayerData): OwnedGamesAnalysis => {
14 | const games = player.ownedGames;
15 | let ownedGames: number | undefined;
16 | let score: number;
17 | if (games?.length) {
18 | ownedGames = games.length;
19 | const cappedOwnedGames = ownedGames >= MAXIMUM_GAMES ? MAXIMUM_GAMES : ownedGames; // Cap at maximum value
20 | score = cappedOwnedGames * VALUE_SCORE_MULTIPLIER + OFFSET;
21 | } else {
22 | score = PRIVATE_PROFILE_SCORE;
23 | }
24 | return {
25 | ownedGames,
26 | score,
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/src/analyze/played-with-bans.ts:
--------------------------------------------------------------------------------
1 | import { ScorePlotData } from '../common/types';
2 | import { range } from '../common/util';
3 | import { PlayerData } from '../gather';
4 | import { Analysis } from './common';
5 | import L from '../common/logger';
6 |
7 | export interface PlayedWithBansAnalysis extends Analysis {
8 | gamesWithBannedRegularTeammate?: number;
9 | }
10 | const BANNEDS_PLAYED_WITH_MULTIPLIER = -2;
11 |
12 | const playedWithBansScoreFunction = (gamesWithBannedRegularTeammate: number): number => {
13 | return gamesWithBannedRegularTeammate * BANNEDS_PLAYED_WITH_MULTIPLIER;
14 | };
15 |
16 | export const analyzePlayedWithBans = (player: PlayerData): PlayedWithBansAnalysis => {
17 | const { csgoStatsDeepPlayedWith } = player;
18 | let score = 0;
19 | let gamesWithBannedRegularTeammate: number | undefined;
20 | const link = `https://csgostats.gg/player/${player.steamId.getSteamID64()}?type=comp#/players`;
21 |
22 | const bannedPlayedWith = csgoStatsDeepPlayedWith?.root.filter(
23 | // A player can be vac_banned but not is_banned, which I think just means they were banned in a non-CSGO game
24 | // This excludes that case
25 | (pw) => pw.details.is_banned && pw.stats.games > 5
26 | );
27 | if (bannedPlayedWith?.length) {
28 | L.debug({ bannedPlayedWith });
29 | gamesWithBannedRegularTeammate = bannedPlayedWith.reduce(
30 | (acc, curr) => acc + curr.stats.games,
31 | 0
32 | );
33 | score = playedWithBansScoreFunction(gamesWithBannedRegularTeammate);
34 | }
35 | score = Math.min(0, score); // Don't get positive points
36 | return {
37 | gamesWithBannedRegularTeammate,
38 | score,
39 | link,
40 | };
41 | };
42 |
43 | const plotBannedFriendsPlayedWithRateData = (): ScorePlotData => {
44 | const x = range(0, 100);
45 | const y = x.map((xVal) => playedWithBansScoreFunction(xVal));
46 | return {
47 | title: 'Number of people played with a significant amount that got banned',
48 | x,
49 | xAxisLabel: 'Number of players',
50 | y,
51 | };
52 | };
53 | export const playedWithBansPlot: ScorePlotData[] = [plotBannedFriendsPlayedWithRateData()];
54 |
--------------------------------------------------------------------------------
/src/analyze/player-bans.ts:
--------------------------------------------------------------------------------
1 | import { PlayerData } from '../gather';
2 | import { Analysis } from './common';
3 |
4 | export interface PlayerBansAnalysis extends Analysis {
5 | communityBanned?: boolean;
6 | economyBan?: boolean;
7 | vacBans?: number;
8 | gameBans?: number;
9 | daysSinceLastBan?: number;
10 | }
11 |
12 | const VAC_BAN_MULTIPLIER = -150;
13 | const GAME_BAN_MULTIPLIER = -100;
14 | const BAN_LENGTH_MULTIPLIER = 0.5; // banned yesterday = -548 points
15 | const BAN_LENGTH_OFFSET = 365 * 3; // 3 years incurs no recency penalty
16 |
17 | export const analyzePlayerBans = (player: PlayerData): PlayerBansAnalysis => {
18 | const { playerBans } = player;
19 | let score = 0;
20 | const communityBanned = playerBans?.communityBanned || undefined;
21 | const economyBan = playerBans?.economyBan !== 'none' || undefined;
22 | const vacBans = playerBans?.vacBans || undefined;
23 | const gameBans = playerBans?.gameBans || undefined;
24 | const daysSinceLastBan = playerBans?.daysSinceLastBan || undefined;
25 | if (playerBans) {
26 | if (communityBanned) score -= 15;
27 | if (economyBan) score -= 30;
28 | if (vacBans) score += VAC_BAN_MULTIPLIER * vacBans;
29 | if (gameBans) score += GAME_BAN_MULTIPLIER * gameBans;
30 | if (daysSinceLastBan) score += (daysSinceLastBan - BAN_LENGTH_OFFSET) * BAN_LENGTH_MULTIPLIER;
31 | score = Math.min(0, score); // Don't get positive points for being banned
32 | }
33 | return {
34 | communityBanned,
35 | economyBan,
36 | vacBans,
37 | gameBans,
38 | daysSinceLastBan,
39 | score,
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/src/analyze/rank.ts:
--------------------------------------------------------------------------------
1 | import { MatchmakingRank } from 'csgostatsgg-scraper';
2 | import moment from 'moment';
3 | import weightedAvg from '@stdlib/stats-incr-wmean';
4 | import { PlayerData } from '../gather';
5 | import { Analysis } from './common';
6 | import L from '../common/logger';
7 | import { ScorePlotData } from '../common/types';
8 | import { range } from '../common/util';
9 |
10 | // TODO: Find all derank periods, scale by age, and add up scores
11 | export interface RankAnalysis extends Analysis {
12 | currentRank?: string;
13 | bestRankEver?: string;
14 | bestRankAgo?: string;
15 | bestRankPastYear?: string;
16 | weightedRank?: string;
17 | worstDerankRate?: string;
18 | }
19 |
20 | const rankName: Record = {
21 | 1: 'Silver 1 (1)',
22 | 2: 'Silver 2 (2)',
23 | 3: 'Silver 3 (3)',
24 | 4: 'Silver 4 (4)',
25 | 5: 'Silver Elite (5)',
26 | 6: 'Silver Elite Master (6)',
27 | 7: 'Gold Nova 1 (7)',
28 | 8: 'Gold Nova 2 (8)',
29 | 9: 'Gold Nova 3 (9)',
30 | 10: 'Gold Nova Master (10)',
31 | 11: 'Master Guardian 1 (11)',
32 | 12: 'Master Guardian 2 (12)',
33 | 13: 'Master Guardian Elite (13)',
34 | 14: 'Distinguished Master Guardian (14)',
35 | 15: 'Legendary Eagle (15)',
36 | 16: 'Legendary Eagle Master (16)',
37 | 17: 'Supreme Master First Class (17)',
38 | 18: 'Global Elite (18)',
39 | };
40 | const MIN_DERANK_AMOUNT = 3;
41 |
42 | const WORST_DERANK_RATE_SCORE_MUILTIPLIER = -10;
43 | const WORST_DERANK_RATE_SCORE_OFFSET = 15;
44 | const WEGITHED_RANK_DIFFERENCE_SCORE_MUILTIPLIER = -35;
45 | const WEGITHED_RANK_DIFFERENCE_SCORE_OFFSET = 40;
46 | const NO_DERANK_SCORE = 0;
47 | const NO_DATA_SCORE = -10;
48 | const YEARS_AGO_WT_ZERO_POINT = 5;
49 |
50 | // TODO: scale worst derank rate to how long ago it occurred (like VAC ban)
51 | const worstDerankRateScoreFunction = (deranksPerMonth: number): number => {
52 | // score = Min(-10 * deranksPerMonth + 15, 0)
53 | // Drop 1 rank in a month: 0
54 | // Drop 2 ranks in a month: -5
55 | // Drop 3 ranks in a month: -15
56 | // Drop 4 ranks in a month: -25
57 | return Math.min(
58 | deranksPerMonth * WORST_DERANK_RATE_SCORE_MUILTIPLIER + WORST_DERANK_RATE_SCORE_OFFSET,
59 | 0
60 | );
61 | };
62 |
63 | const weightedRankDifferenceScoreFunction = (weightedRankDifference: number): number => {
64 | // score = Min(-35 * weightedRankDifference + 40, 0)
65 | // +1 rank: 0
66 | // +2 rank: -30
67 | // +3 rank: -65
68 | // +4 rank: -100
69 | return Math.min(
70 | weightedRankDifference * WEGITHED_RANK_DIFFERENCE_SCORE_MUILTIPLIER +
71 | WEGITHED_RANK_DIFFERENCE_SCORE_OFFSET,
72 | 0
73 | );
74 | };
75 |
76 | export const analyzeRank = (player: PlayerData): RankAnalysis => {
77 | let bestRankValue = player.csgoStatsPlayer?.summary.bestRank;
78 | let currentRankValue: MatchmakingRank | undefined = player.csgoStatsPlayer?.summary.currentRank;
79 | const rawData = player.csgoStatsPlayer?.graphs?.rawData;
80 | let score: number = NO_DATA_SCORE;
81 | let worstDerankRate: string | undefined;
82 | let bestRankAgo: string | undefined;
83 |
84 | let bestRankPastYear: string | undefined;
85 | let weightedRankValue: number | undefined;
86 | let weightedRank: string | undefined;
87 | let derankRateScore: number;
88 | let weightedRankDifferenceScore: number;
89 | const link = `https://csgostats.gg/player/${player.steamId.getSteamID64()}?type=comp#/graphs`;
90 |
91 | if (rawData) {
92 | const reversedRawData = rawData.reverse();
93 |
94 | // CURRENT RANK REFINEMENT
95 |
96 | reversedRawData.some((point) => {
97 | if (point.rank >= 0) {
98 | currentRankValue = point.rank;
99 | return true;
100 | }
101 | return false; // Continue loop
102 | });
103 |
104 | // BEST RANK REFINEMENT
105 |
106 | let recentBestRankIndex = 0;
107 | const recentBestRankPoint = reversedRawData.reduce((recentBestRank, point, index) => {
108 | if (point.rank !== 0 && point.rank > recentBestRank.rank) {
109 | recentBestRankIndex = index;
110 | return point;
111 | }
112 | return recentBestRank;
113 | });
114 | bestRankValue = recentBestRankPoint ? recentBestRankPoint.rank : bestRankValue;
115 |
116 | if (bestRankValue && currentRankValue) {
117 | // WEIGHTED RANK
118 |
119 | const accumulator = weightedAvg();
120 | weightedRankValue = reversedRawData.reduce((acc, point) => {
121 | if (point.rank <= 0) return acc;
122 | const yearsAgo = moment().diff(moment(point.date, 'YYYY-MM-DD HH:mm:ss'), 'years', true);
123 | const weight = Math.max(0, YEARS_AGO_WT_ZERO_POINT - yearsAgo);
124 | return accumulator(point.rank, weight) as number;
125 | }, 0);
126 | weightedRank = weightedRankValue
127 | ? rankName[Math.round(weightedRankValue) as MatchmakingRank].replace(
128 | /\(\d+\)/,
129 | `(${weightedRankValue.toFixed(1)})`
130 | )
131 | : undefined;
132 |
133 | const weightedRankDifference = weightedRankValue - currentRankValue;
134 | weightedRankDifferenceScore = weightedRankDifferenceScoreFunction(weightedRankDifference);
135 |
136 | L.debug({
137 | weightedRankDifference,
138 | weightedRankDifferenceScore,
139 | });
140 |
141 | // BEST RANK PAST YEAR
142 |
143 | let bestRankPastYearValue: MatchmakingRank | undefined;
144 | reversedRawData.some((point) => {
145 | if (moment().diff(moment(point.date, 'YYYY-MM-DD HH:mm:ss'), 'years', true) > 1) {
146 | return true; // Break from loop
147 | }
148 | if (point.rank !== 0) {
149 | if (!bestRankPastYearValue || point.rank > bestRankPastYearValue) {
150 | bestRankPastYearValue = point.rank;
151 | }
152 | }
153 | return false; // Continue loop
154 | });
155 | bestRankPastYear = bestRankPastYearValue ? rankName[bestRankPastYearValue] : undefined;
156 |
157 | // DERANK
158 | if (recentBestRankPoint.rank > currentRankValue) {
159 | // ⬆ This should catch the reduce on an empty array
160 | const recentWorstRankPoint = reversedRawData
161 | .slice(0, recentBestRankIndex)
162 | .reduce((worstRank, point) => {
163 | if (point.rank !== 0 && point.rank <= worstRank.rank) return point;
164 | return worstRank;
165 | });
166 | const derankAmount = recentBestRankPoint.rank - recentWorstRankPoint.rank;
167 | if (derankAmount < MIN_DERANK_AMOUNT) {
168 | // Only count larger derank intervals
169 | derankRateScore = NO_DERANK_SCORE;
170 | } else {
171 | const bestRankDate = moment(recentBestRankPoint.date, 'YYYY-MM-DD HH:mm:ss'); // 2017-10-27 00:51:10
172 | bestRankAgo = bestRankDate.fromNow();
173 | const worstRankDate = moment(recentWorstRankPoint.date, 'YYYY-MM-DD HH:mm:ss'); // 2017-10-27 00:51:10
174 | const derankDuration = worstRankDate.diff(bestRankDate, 'months', true); // Returns floating point number of months
175 | const derankRateValue = derankAmount / derankDuration;
176 |
177 | derankRateScore = worstDerankRateScoreFunction(derankRateValue);
178 | worstDerankRate = `${derankAmount} ranks ${bestRankDate.to(worstRankDate)}`;
179 |
180 | L.debug({
181 | bestRankDate: bestRankDate.toISOString(),
182 | worstRankDate: worstRankDate.toISOString(),
183 | derankDuration,
184 | derankRateValue,
185 | derankRateScore,
186 | });
187 | }
188 | } else {
189 | derankRateScore = NO_DERANK_SCORE;
190 | }
191 | score = Math.min(derankRateScore + weightedRankDifferenceScore, 0); // Cap at 0
192 | L.debug({
193 | rankScore: score,
194 | });
195 | }
196 | }
197 |
198 | const currentRank: string | undefined = currentRankValue ? rankName[currentRankValue] : undefined;
199 | const bestRankEver: string | undefined = bestRankValue ? rankName[bestRankValue] : undefined;
200 | return {
201 | currentRank,
202 | weightedRank,
203 | bestRankPastYear,
204 | bestRankEver,
205 | bestRankAgo,
206 | worstDerankRate,
207 | score,
208 | link,
209 | };
210 | };
211 |
212 | const plotDerankRateData = (): ScorePlotData => {
213 | const x = range(0, 20);
214 | const y = x.map((xVal) => worstDerankRateScoreFunction(xVal));
215 | return {
216 | title: 'Worst Derank Period Rate',
217 | x,
218 | xAxisLabel: 'Deranks per month',
219 | y,
220 | };
221 | };
222 |
223 | const plotWeightedRankDifferenceData = (): ScorePlotData => {
224 | const x = range(-5, 17);
225 | const y = x.map((xVal) => weightedRankDifferenceScoreFunction(xVal));
226 | return {
227 | title: 'Weighted Rank Difference',
228 | x,
229 | xAxisLabel: 'Weighted rank - current rank',
230 | y,
231 | };
232 | };
233 |
234 | export const rankPlot: ScorePlotData[] = [plotDerankRateData(), plotWeightedRankDifferenceData()];
235 |
--------------------------------------------------------------------------------
/src/analyze/steam-level.ts:
--------------------------------------------------------------------------------
1 | import { PlayerData } from '../gather';
2 | import { Analysis } from './common';
3 |
4 | export interface SteamLevelAnalysis extends Analysis {
5 | steamLevel?: number;
6 | experience?: number;
7 | }
8 |
9 | const LEVEL_SCORE_MULTIPLIER = 0.5;
10 | const OFFSET = -2; // Level 4 account gets 0
11 | const PRIVATE_PROFILE_SCORE = -2;
12 |
13 | export const analyzeSteamLevel = (player: PlayerData): SteamLevelAnalysis => {
14 | const { steamLevel } = player;
15 | const experience = player.badges?.playerXP;
16 | let score: number;
17 | if (steamLevel) {
18 | score = steamLevel * LEVEL_SCORE_MULTIPLIER + OFFSET;
19 | } else {
20 | score = PRIVATE_PROFILE_SCORE;
21 | }
22 | return {
23 | steamLevel,
24 | experience,
25 | score,
26 | };
27 | };
28 |
--------------------------------------------------------------------------------
/src/analyze/steamrep.ts:
--------------------------------------------------------------------------------
1 | import { PlayerData } from '../gather';
2 | import { Analysis } from './common';
3 |
4 | export interface SteamRepAnalysis extends Analysis {
5 | steamRep?: string;
6 | }
7 |
8 | const BAD_REP_SCORE = -100;
9 | const NO_DATA_SCORE = 0;
10 |
11 | export const analyzeSteamRep = (player: PlayerData): SteamRepAnalysis => {
12 | const { steamReputation } = player;
13 | let score: number;
14 | let link: string | undefined;
15 | let steamRep: string | undefined;
16 | if (steamReputation && steamReputation !== 'none') {
17 | score = BAD_REP_SCORE;
18 | link = `https://steamrep.com/search?q=${player.steamId.getSteamID64()}`;
19 | steamRep = steamReputation;
20 | } else {
21 | score = NO_DATA_SCORE;
22 | }
23 | return {
24 | score,
25 | link,
26 | steamRep,
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/src/charts/generate.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import { plot, stack } from 'nodeplotlib';
3 |
4 | import { accountAgePlot } from '../analyze/account-age';
5 | import { compMatchWinsPlot } from '../analyze/comp-match-count';
6 | import { friendBansPlot } from '../analyze/friend-bans';
7 | import { gameHoursPlot } from '../analyze/game-hours';
8 | import { rankPlot } from '../analyze/rank';
9 | import { playedWithBansPlot } from '../analyze/played-with-bans';
10 |
11 | export const generateCharts = async (): Promise => {
12 | const plots = [
13 | accountAgePlot,
14 | compMatchWinsPlot,
15 | gameHoursPlot,
16 | rankPlot,
17 | friendBansPlot,
18 | playedWithBansPlot,
19 | ].flat();
20 | plots.forEach((plotElem) => {
21 | stack([{ x: plotElem.x, y: plotElem.y, type: 'scatter' }], {
22 | title: plotElem.title,
23 | xaxis: { title: { text: plotElem.xAxisLabel } },
24 | yaxis: { title: { text: plotElem.yAxisLabel || 'Score' } },
25 | });
26 | });
27 | plot();
28 | };
29 |
30 | generateCharts();
31 |
--------------------------------------------------------------------------------
/src/common/logger.ts:
--------------------------------------------------------------------------------
1 | import pino from 'pino';
2 | import 'dotenv/config';
3 |
4 | const logger = pino({
5 | transport: {
6 | target: 'pino-pretty',
7 | options: {
8 | translateTime: `SYS:standard`,
9 | },
10 | },
11 | formatters: {
12 | level: (label) => {
13 | return { level: label };
14 | },
15 | },
16 | level: process.env.LOG_LEVEL || 'info',
17 | base: undefined,
18 | });
19 |
20 | export default logger;
21 |
--------------------------------------------------------------------------------
/src/common/types.ts:
--------------------------------------------------------------------------------
1 | export interface ScorePlotData {
2 | title: string;
3 | x: number[];
4 | xAxisLabel: string;
5 | y: number[];
6 | yAxisLabel?: string;
7 | }
8 |
--------------------------------------------------------------------------------
/src/common/util.ts:
--------------------------------------------------------------------------------
1 | import SteamID from 'steamid';
2 | import 'dotenv/config';
3 | import { AxiosError } from 'axios';
4 | import TinyGradient from 'tinygradient';
5 | import fs from 'fs-extra';
6 | import path from 'path';
7 | import type { PackageJson } from 'types-package-json';
8 | import Keyv from 'keyv';
9 | import L from './logger';
10 |
11 | export const parseStatus = (status: string): SteamID[] => {
12 | const steam2Ids = status.match(/(STEAM_[0-5]:[0-1]:[0-9]+)/g);
13 | if (!steam2Ids) return [];
14 | return steam2Ids.map((id) => new SteamID(id));
15 | };
16 |
17 | export const getCacheDir = (): string => {
18 | return process.env.CACHE_DIR || '/tmp';
19 | };
20 |
21 | export const cleanAxiosResponse = (err: AxiosError) => {
22 | const { response } = err;
23 | return {
24 | status: response?.status,
25 | headers: response?.headers,
26 | data: response?.data,
27 | };
28 | };
29 |
30 | export const getScoreColor = (totalScore: number): string => {
31 | const MIN_SCORE = -100;
32 | const MAX_SCORE = 500;
33 | const SCORE_RANGE = Math.abs(MIN_SCORE) + Math.abs(MAX_SCORE);
34 | const ZERO_POS = Math.abs(MIN_SCORE) / SCORE_RANGE;
35 | const colorGradient = new TinyGradient([
36 | { color: '#222222', pos: 0 },
37 | { color: 'red', pos: ZERO_POS / 2 },
38 | { color: 'yellow', pos: ZERO_POS },
39 | { color: 'blue', pos: 1 },
40 | ]);
41 | const colorPos =
42 | (Math.min(MAX_SCORE, Math.max(MIN_SCORE, totalScore)) + SCORE_RANGE * ZERO_POS) / SCORE_RANGE;
43 | L.trace({ colorPos });
44 | const color = colorGradient.hsvAt(colorPos).toHexString();
45 | return color;
46 | };
47 |
48 | let packageJsonCache: PackageJson | undefined;
49 | export const packageJson = (): PackageJson => {
50 | if (packageJsonCache) return packageJsonCache;
51 | packageJsonCache = fs.readJSONSync(path.resolve(process.cwd(), `package.json`));
52 | return packageJsonCache as PackageJson;
53 | };
54 |
55 | export const getVersion = (): string => {
56 | const { COMMIT_SHA } = process.env;
57 | let { version } = packageJson();
58 | if (COMMIT_SHA) version = `${version}#${COMMIT_SHA.substring(0, 8)}`;
59 | return version;
60 | };
61 |
62 | export const range = (start: number, stop: number, step?: number): number[] => {
63 | const a = [start];
64 | let b = start;
65 | while (b < stop) {
66 | a.push((b += step || 1));
67 | }
68 | return a;
69 | };
70 |
71 | let keyv: Keyv | undefined;
72 | export const getCache = (): Keyv => {
73 | const DB_FILE_PATH = `${getCacheDir()}/csgo-sus-cache.sqlite`;
74 | if (!keyv) {
75 | if (fs.existsSync(DB_FILE_PATH)) {
76 | const dbSize = fs.statSync(DB_FILE_PATH).size;
77 | if (dbSize > 100000000) {
78 | // TODO: Hack for corrupted DB prevention - delete when > 100MB
79 | fs.unlinkSync(DB_FILE_PATH);
80 | }
81 | }
82 | keyv = new Keyv(`sqlite://${DB_FILE_PATH}`, {
83 | namespace: 'csgosus',
84 | });
85 | keyv.on('error', (err) => L.error(err));
86 | }
87 | return keyv;
88 | };
89 |
90 | export const chunkArray = (initArray: T[], size: number): T[][] => {
91 | return Array.from(new Array(Math.ceil(initArray.length / size)), (_, i) =>
92 | initArray.slice(i * size, i * size + size)
93 | );
94 | };
95 |
--------------------------------------------------------------------------------
/src/discord/bot.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Client,
3 | ColorResolvable,
4 | EmbedFieldData,
5 | Intents,
6 | MessageEmbed,
7 | MessageOptions,
8 | } from 'discord.js';
9 | import 'dotenv/config';
10 | import SteamID from 'steamid';
11 | import type { PackageJsonPerson } from 'types-package-json';
12 | import { AnalysesEntry, analyzePlayer, analyzePlayers, PlayerAnalysis } from '../analyze';
13 | import { getScoreColor, getVersion, packageJson, parseStatus } from '../common/util';
14 | import { getPlayerData, getPlayersData } from '../gather';
15 | import { deployCommands } from './deploy-commands';
16 | import L from '../common/logger';
17 |
18 | const token = process.env.DISCORD_BOT_TOKEN || 'missing';
19 |
20 | const client = new Client({
21 | intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES],
22 | presence: {
23 | activities: [
24 | {
25 | type: 'COMPETING',
26 | name: 'CSGO | "status"',
27 | url: packageJson().homepage,
28 | },
29 | ],
30 | },
31 | });
32 |
33 | function generateInviteUrl(): string {
34 | return client.generateInvite({
35 | scopes: ['bot', 'applications.commands'],
36 | permissions: ['VIEW_CHANNEL', 'SEND_MESSAGES', 'EMBED_LINKS'],
37 | });
38 | }
39 |
40 | client.on('debug', (m) => L.trace(m));
41 | client.on('warn', (m) => L.warn(m));
42 | client.on('error', (m) => L.error(m));
43 |
44 | client.on('ready', async (readyClient) => {
45 | L.info({ inviteUrl: generateInviteUrl() }, 'Discord bot logged in');
46 | await deployCommands(readyClient.user.id);
47 | });
48 |
49 | export function mapAnalysisDetailsToField(
50 | prefixSymbol: string,
51 | analyses?: AnalysesEntry[]
52 | ): EmbedFieldData[] {
53 | if (!analyses || !analyses.length) return [];
54 | const fields: (EmbedFieldData | null)[] = analyses.map(([analysisType, details]) => {
55 | L.trace({ analysisType, details });
56 | const detailsEntries = Object.entries(details).filter(
57 | ([key]) => !['score', 'link'].includes(key)
58 | );
59 | L.trace({ detailsEntries });
60 | if (!detailsEntries || !detailsEntries.length) return null;
61 | const detailsListPrefix = details.link ? `[Details...](${details.link})\n` : '';
62 | const detailsList = detailsEntries.reduce((acc, [key, value]) => {
63 | let valueString: string;
64 | try {
65 | if (typeof value === 'number') {
66 | valueString = Number.isInteger(value) ? value.toString() : value.toFixed(2);
67 | } else valueString = value.toString();
68 | return `${acc}**${key}:** ${valueString}\n`;
69 | } catch {
70 | return acc;
71 | }
72 | }, detailsListPrefix);
73 | L.trace({ detailsList });
74 |
75 | if (!detailsList) return null;
76 | const field: EmbedFieldData = {
77 | name: `${prefixSymbol}${analysisType}`,
78 | value: detailsList,
79 | };
80 | L.trace({ field }, 'Mapped analysis detail to field');
81 | return field;
82 | });
83 | return fields.filter((elem): elem is EmbedFieldData => elem !== null);
84 | }
85 |
86 | export function analysisToEmbed(analysis: PlayerAnalysis): MessageEmbed {
87 | const { totalScore } = analysis;
88 |
89 | const color = getScoreColor(totalScore) as ColorResolvable;
90 | L.trace({ color, nickname: analysis.nickname }, 'Chose color');
91 |
92 | const roundedTotalScore = totalScore > 0 ? Math.ceil(totalScore) : Math.floor(totalScore);
93 | const fields: EmbedFieldData[] = [
94 | {
95 | name: 'SussyScore™',
96 | value: roundedTotalScore.toString(),
97 | },
98 | ];
99 | L.trace('Mapping negative fields');
100 | const negativeFields = mapAnalysisDetailsToField('❗', analysis.negativeAnalyses);
101 | L.trace('Pusing negative fields');
102 | if (negativeFields && negativeFields.length) fields.push(...negativeFields);
103 | L.trace('Mapping positive fields');
104 | const positiveFields = mapAnalysisDetailsToField('✅', analysis.positiveAnalyses);
105 | if (positiveFields && positiveFields.length) fields.push(...positiveFields);
106 |
107 | L.trace('Creating embed');
108 | const embed = new MessageEmbed({
109 | color,
110 | author: {
111 | name: packageJson().name,
112 | url: packageJson().homepage,
113 | },
114 | thumbnail: {
115 | url: analysis.profileImage,
116 | },
117 | title: analysis.nickname,
118 | url: analysis.profileLink,
119 | // description: `${analysis.steamId.steam2()}/${analysis.steamId.steam3()}/${analysis.steamId.getSteamID64()}`,
120 | fields,
121 | });
122 | L.trace({ embed }, 'Created embed');
123 | return embed;
124 | }
125 |
126 | export async function investigateSteamIds(
127 | steamIds: SteamID[],
128 | followUp: (opts: Omit) => Promise,
129 | editReply: (opts: Omit) => Promise
130 | ): Promise {
131 | const embeds: MessageEmbed[] = [];
132 | L.debug({ steamIds: steamIds.map((id) => id.getSteamID64()) }, 'Detected some steam IDs');
133 | L.debug('Gathering and analyzing players');
134 | const playersDataPromises = await getPlayersData(steamIds);
135 | await Promise.all(
136 | playersDataPromises.map((promise) =>
137 | promise.then(async (playerData) => {
138 | L.debug(`Finished gathering for ${playerData.summary?.nickname}`);
139 | const analysis = analyzePlayer(playerData);
140 | const embed = analysisToEmbed(analysis);
141 | embeds.push(embed);
142 | L.debug(`Following up reply with embed number ${embeds.length}`);
143 | if (embeds.length === steamIds.length)
144 | await editReply({ content: `Investigated ${steamIds.length} users:` });
145 | await followUp({ embeds: [embed] });
146 | L.debug(`Finished editing with ${embeds.length} embeds`);
147 | })
148 | )
149 | );
150 |
151 | L.trace('Finished investigation');
152 | }
153 |
154 | try {
155 | client.on('messageCreate', async (message) => {
156 | const steamIds = parseStatus(message.content);
157 | if (steamIds.length < 1) return;
158 | // Send a message containing the status message (if the bot has message permissions)
159 | L.info(`Detected ${steamIds.length} steamIds in a message, investigating...`);
160 | const reply = await message.reply(`Investigating ${steamIds.length} users...`);
161 | await investigateSteamIds(steamIds, reply.reply.bind(reply), reply.edit.bind(reply));
162 | });
163 |
164 | client.on('interactionCreate', async (interaction) => {
165 | if (interaction.isContextMenu()) {
166 | const { commandName } = interaction;
167 | if (commandName === 'Investigate status') {
168 | // Right click on a message containing a status message
169 | const message = interaction.options.getMessage('message', true);
170 | const steamIds = parseStatus(message.content);
171 | L.info(`Investigating ${steamIds.length} steamId from Message Command`);
172 | if (!steamIds.length) return;
173 | L.trace('Deferring reply');
174 | await interaction.deferReply();
175 | await investigateSteamIds(
176 | steamIds,
177 | interaction.followUp.bind(interaction),
178 | interaction.editReply.bind(interaction)
179 | );
180 | }
181 | } else if (interaction.isCommand()) {
182 | const { commandName } = interaction;
183 | /**
184 | * Discord doesn't support multiline options, so no status command
185 | * https://github.com/discord/discord-api-docs/issues/2381
186 | * Instead, it supports detecting message content, or right click menu
187 | */
188 | if (commandName === 'user') {
189 | // Use the /user command
190 | const steamId = interaction.options.getString('id', true);
191 | L.info(`Responding to /user command for ${steamId}`);
192 | await interaction.deferReply();
193 | try {
194 | const playerData = await getPlayerData(steamId);
195 | L.debug('Gathered player data. Analyzing...');
196 | const analyzedResults = analyzePlayers([playerData]);
197 | L.trace('Mapping result to embed');
198 | const embeds = analyzedResults.map(analysisToEmbed);
199 | await interaction.editReply({ embeds });
200 | } catch (err) {
201 | L.error(err);
202 | }
203 | }
204 | if (commandName === 'help') {
205 | // Use the /help command
206 | L.info(`Responding to /help command`);
207 | const embed = new MessageEmbed({
208 | author: {
209 | name: packageJson().name,
210 | url: packageJson().homepage,
211 | },
212 | title: 'Help',
213 | fields: [
214 | {
215 | name: 'Invesigate with a message',
216 | value: `To investigate multiple players in your game:
217 | • In CSGO: type \`status\` in console and copy the list of players
218 | • Paste it as a message in Discord and wait for the results`,
219 | },
220 | {
221 | name: 'Invesigate with a right click',
222 | value: `If this Discord bot doesn't have permission to read messages, you can still use it:
223 | • In CSGO: type \`status\` in console and copy the list of players
224 | • Paste it as a message in Discord
225 | • Right click the message > Apps > Investigate status`,
226 | },
227 | {
228 | name: 'Invesigate a single user',
229 | value: `Use the \`/user\` command and provide any SteamID version or profile link`,
230 | },
231 | ],
232 | footer: {
233 | text: `${packageJson().name} v${getVersion()} by ${
234 | (packageJson().author as PackageJsonPerson).name
235 | }`,
236 | },
237 | });
238 | await interaction.reply({ embeds: [embed] });
239 | }
240 | }
241 | });
242 | } catch (err) {
243 | L.error(err);
244 | }
245 |
246 | client.login(token);
247 |
--------------------------------------------------------------------------------
/src/discord/deploy-commands.ts:
--------------------------------------------------------------------------------
1 | import { SlashCommandBuilder } from '@discordjs/builders';
2 | import { REST } from '@discordjs/rest';
3 | import {
4 | ApplicationCommandType,
5 | RESTPostAPIApplicationCommandsJSONBody,
6 | Routes,
7 | } from 'discord-api-types/v9';
8 | import 'dotenv/config';
9 | import { packageJson } from '../common/util';
10 |
11 | const token = process.env.DISCORD_BOT_TOKEN || 'missing';
12 | const guild = process.env.DISCORD_DEV_GUILD_ID;
13 |
14 | const helpSlashCommand = new SlashCommandBuilder()
15 | .setName('help')
16 | .setDescription(`How to use ${packageJson().name}`);
17 |
18 | const userSlashCommand = new SlashCommandBuilder()
19 | .setName('user')
20 | .setDescription('Investigate a user using their Steam profile')
21 | .addStringOption((option) =>
22 | option.setName('id').setDescription('Any Steam ID version or profile link').setRequired(true)
23 | );
24 |
25 | // @discordjs/builders doesn't support Message Commands yet: https://discord.com/developers/docs/interactions/application-commands#message-commands
26 | const statusMessageCommandJson: RESTPostAPIApplicationCommandsJSONBody = {
27 | name: 'Investigate status',
28 | type: ApplicationCommandType.Message,
29 | };
30 |
31 | const commands = [helpSlashCommand.toJSON(), userSlashCommand.toJSON(), statusMessageCommandJson];
32 |
33 | export async function deployCommands(clientId: string) {
34 | const rest = new REST({ version: '9' }).setToken(token);
35 | if (guild) {
36 | await rest.put(Routes.applicationGuildCommands(clientId, guild), { body: commands });
37 | } else {
38 | await rest.put(Routes.applicationCommands(clientId), { body: commands });
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/gather/csgostats.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CSGOStatsGGScraper,
3 | PlayedWith,
4 | PlayedWithFilterParams,
5 | PlayerFilterParams,
6 | PlayerOutput,
7 | } from 'csgostatsgg-scraper';
8 | import { getCache } from '../common/util';
9 |
10 | export class CSGOStatsGGScraperCache extends CSGOStatsGGScraper {
11 | private namespace = `csgostatsgg-scraper`;
12 |
13 | private ttl = 1000 * 60 * 60 * 24 * 7; // 1 week
14 |
15 | private cache = getCache();
16 |
17 | public async getPlayedWith(
18 | steamId64: string,
19 | filterParams?: PlayedWithFilterParams
20 | ): Promise {
21 | const cacheKey = `${this.namespace}:played-with-${steamId64}`;
22 | const data = await this.cache.get(cacheKey);
23 | if (data) {
24 | return data;
25 | }
26 | const resp = await super.getPlayedWith(steamId64, filterParams);
27 | await this.cache.set(cacheKey, resp, this.ttl);
28 | return resp;
29 | }
30 |
31 | public async getPlayer(
32 | anySteamId: string | bigint,
33 | filterParams?: PlayerFilterParams
34 | ): Promise {
35 | const cacheKey = `${this.namespace}:player-${anySteamId}`;
36 | const data = await this.cache.get(cacheKey);
37 | if (data) {
38 | return data;
39 | }
40 | const resp = await super.getPlayer(anySteamId, filterParams);
41 | await this.cache.set(cacheKey, resp, this.ttl);
42 | return resp;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/gather/faceit.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { getCache } from '../common/util';
3 |
4 | export interface Game {
5 | region: string;
6 | game_player_id: string;
7 | skill_level: number;
8 | faceit_elo: number;
9 | game_player_name: string;
10 | skill_level_label: string;
11 | regions: Record;
12 | game_profile_id: string;
13 | }
14 |
15 | export type Games = Record;
16 |
17 | export interface Platforms {
18 | steam: string;
19 | }
20 |
21 | export interface Settings {
22 | language: string;
23 | }
24 |
25 | export interface FaceitPlayerDetails {
26 | player_id: string;
27 | nickname: string;
28 | avatar: string;
29 | country: string;
30 | cover_image: string;
31 | platforms: Platforms;
32 | games: Games;
33 | settings: Settings;
34 | friends_ids: string[];
35 | new_steam_id: string;
36 | steam_id_64: string;
37 | steam_nickname: string;
38 | memberships: string[];
39 | faceit_url: string;
40 | membership_type: string;
41 | cover_featured_image: string;
42 | infractions: Record;
43 | }
44 |
45 | export interface BanPayload {
46 | nickname: string;
47 | type: string;
48 | reason: string;
49 | game: null;
50 | starts_at: string;
51 | ends_at: null;
52 | user_id: string;
53 | }
54 |
55 | export interface FaceitBanResponse {
56 | code: string;
57 | env: string;
58 | message: string;
59 | payload: BanPayload[];
60 | time: number;
61 | version: string;
62 | }
63 |
64 | export type FaceitPlayer = Pick<
65 | FaceitPlayerDetails,
66 | 'player_id' | 'nickname' | 'country' | 'faceit_url'
67 | >;
68 |
69 | export interface FaceitData {
70 | player: FaceitPlayer;
71 | bans: BanPayload[];
72 | csgoData?: Game;
73 | }
74 |
75 | export class FaceitCache {
76 | private namespace = `faceit`;
77 |
78 | private ttl = 1000 * 60 * 60 * 24 * 7; // 1 week
79 |
80 | private cache = getCache();
81 |
82 | public async getFaceitData(steamId64: string): Promise {
83 | const cacheKey = `${this.namespace}:faceit-${steamId64}`;
84 | const data = await this.cache.get(cacheKey);
85 | if (data === '') return undefined;
86 | if (data) return data;
87 | let faceitData: FaceitData | undefined;
88 | try {
89 | const detailsResp = await axios.get(
90 | 'https://open.faceit.com/data/v4/players',
91 | {
92 | params: {
93 | game_player_id: steamId64,
94 | game: 'csgo',
95 | },
96 | headers: {
97 | Authorization: `Bearer ${process.env.FACEIT_API_KEY}`,
98 | },
99 | }
100 | );
101 | const player: FaceitPlayer = {
102 | player_id: detailsResp.data.player_id,
103 | nickname: detailsResp.data.nickname,
104 | country: detailsResp.data.country,
105 | faceit_url: detailsResp.data.faceit_url,
106 | };
107 | const csgoData: Game | undefined = detailsResp.data.games.csgo;
108 | const banResp = await axios.get(
109 | `https://api.faceit.com/sheriff/v1/bans/${player.player_id}`
110 | );
111 | const bans: BanPayload[] = banResp.data.payload;
112 | faceitData = {
113 | player,
114 | bans,
115 | csgoData,
116 | };
117 | } catch (err) {
118 | if (err.response?.status !== 404) {
119 | throw err;
120 | }
121 | faceitData = undefined;
122 | }
123 | const cacheString = faceitData || ''; // Cache undefined as empty string to prevent future API errors
124 | await this.cache.set(cacheKey, cacheString, this.ttl);
125 | return faceitData;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/src/gather/index.ts:
--------------------------------------------------------------------------------
1 | import SteamApi, { PlayerBans, PlayerSummary, RecentGame } from 'steamapi';
2 | import SteamID from 'steamid';
3 | import { CSGOStatsGGScraper, MatchType, Player, PlayerOutput } from 'csgostatsgg-scraper';
4 | import 'dotenv/config';
5 | import { EventEmitter } from 'events';
6 | import { AxiosError } from 'axios';
7 | import L from '../common/logger';
8 | import { FriendSummary, SteamApiCache } from './steamapi';
9 | import { CSGOStatsGGScraperCache } from './csgostats';
10 | import { InventoryValueCache, InventoryWithValue } from './inventory';
11 | import { ReputationSummary, SteamRepCache } from './steamrep';
12 | import { FaceitCache, FaceitData } from './faceit';
13 | import { cleanAxiosResponse } from '../common/util';
14 |
15 | EventEmitter.defaultMaxListeners = 100; // This doesn't seem to work..
16 |
17 | const steam = new SteamApiCache(process.env.STEAM_API_KEY || '');
18 | const inventory = new InventoryValueCache();
19 | const steamrep = new SteamRepCache();
20 | const faceIt = new FaceitCache();
21 |
22 | export const logErrorValue =
23 | (returnValue: T, logValue?: unknown): ((err: unknown) => T) =>
24 | (err): T => {
25 | if (logValue) L.warn(logValue);
26 | L.error(err);
27 | if ((err as AxiosError)?.response) {
28 | L.error({ axiosResponse: cleanAxiosResponse(err as AxiosError) });
29 | }
30 | return returnValue;
31 | };
32 | export const logError = logErrorValue(undefined);
33 |
34 | /**
35 | * Since accountIds are sequential, we just check the neighbors for their creation date, which should be fairly close
36 | * @param accountId number at the end of SteamID3 ([U:1:XXXXXXXX])
37 | * @returns creation date
38 | */
39 | export const getPrivateCreationDate = async (accountId: number, offset = 1): Promise => {
40 | const MAX_OFFSET = 10; // How many accounts/2 to check before throwing an error
41 | const API_BATCH_SIZE = 10; // How many accounts to query at a time (max 100)
42 |
43 | if (offset >= MAX_OFFSET)
44 | throw new Error(`Could not find public neighbor profile after ${MAX_OFFSET * 2} attempts`);
45 |
46 | // Zigzag the IDs alternating above and below the target
47 | let newOffset = offset;
48 | const neighbors = [];
49 | for (let i = 0; i < API_BATCH_SIZE; i += 1) {
50 | neighbors.push(accountId + newOffset);
51 | if (newOffset < 0) {
52 | newOffset = newOffset * -1 + 1; // When negative, invert to positive and increment
53 | } else {
54 | newOffset *= -1; // When positive, invert to negative
55 | }
56 | }
57 |
58 | // Map the Steam AccountID back to a SteamID64
59 | const userList = neighbors.map((id) => SteamID.fromIndividualAccountID(id).getSteamID64());
60 | // Call the Steam API
61 | const neighborSummaries: PlayerSummary[] = await steam.getUserSummary(userList);
62 |
63 | // They're ordered from closest to furthest, so pick the first public profile
64 | const nearestNeighbor = neighborSummaries.find((p) => p.visibilityState === 3 && p.created); // 3 is public
65 | if (nearestNeighbor) return new Date((nearestNeighbor.created as number) * 1000);
66 | // Else try again recursively
67 | return getPrivateCreationDate(accountId, newOffset);
68 | };
69 |
70 | export interface DeepPlayedWith {
71 | root: Player[];
72 | deep: Player[][];
73 | }
74 |
75 | export interface PlayerData {
76 | steamId: SteamID;
77 | createdAt: Date;
78 | summary?: PlayerSummary;
79 | steamLevel?: number;
80 | ownedGames?: SteamApi.Game[];
81 | recentGames?: RecentGame[];
82 | badges?: SteamApi.PlayerBadges;
83 | friends?: FriendSummary[];
84 | playerBans?: PlayerBans;
85 | csgoStatsPlayer?: PlayerOutput;
86 | csgoStatsDeepPlayedWith?: DeepPlayedWith;
87 | inventory?: InventoryWithValue;
88 | steamReputation?: ReputationSummary;
89 | faceit?: FaceitData;
90 | }
91 |
92 | export const getSignificantPlayedWith = async (steamId: string, scraper: CSGOStatsGGScraper) => {
93 | const SIGNIFICANT_GAMES = 5;
94 | const playedWith = await scraper.getPlayedWith(steamId, { mode: MatchType.COMPETITIVE });
95 | const significantPlayers = playedWith.players.filter((p) => p.stats.games >= SIGNIFICANT_GAMES);
96 | return significantPlayers;
97 | };
98 |
99 | export const getDeepPlayedWith = async (
100 | steamId: string,
101 | scraper: CSGOStatsGGScraper
102 | ): Promise => {
103 | const rootPlayedWith = await getSignificantPlayedWith(steamId, scraper);
104 | const deepPlayedWith = await Promise.all(
105 | rootPlayedWith.map(async (playedWith) => getSignificantPlayedWith(playedWith.steam_id, scraper))
106 | );
107 | return { root: rootPlayedWith, deep: deepPlayedWith };
108 | };
109 |
110 | export const getPlayersData = async (steamIds: SteamID[]): Promise[]> => {
111 | // These APIs support multiple players at once
112 | const steamIds64 = steamIds.map((id) => id.getSteamID64());
113 | const [playerSummaries, playerBans] = await Promise.all([
114 | steam.getUserSummaryOrdered(steamIds64).catch(logErrorValue([], steamIds64)),
115 | steam.getUserBansOrdered(steamIds64).catch(logErrorValue([], steamIds64)),
116 | ]);
117 | const scraper = new CSGOStatsGGScraperCache({
118 | concurrency: 10,
119 | useLocalHero: true,
120 | });
121 |
122 | const playerDataPromises: Promise[] = steamIds.map(async (steamId, index) => {
123 | const summary = playerSummaries[index];
124 | const playerBan = playerBans[index];
125 | const isPublic = summary?.visibilityState === 3;
126 | // TODO: below awaits are not concurrent
127 | return {
128 | steamId,
129 | createdAt: summary?.created
130 | ? new Date(summary.created * 1000)
131 | : await getPrivateCreationDate(steamId.accountid),
132 | summary,
133 | steamLevel: isPublic
134 | ? await steam.getUserLevel(steamId.getSteamID64()).catch(logError)
135 | : undefined,
136 | ownedGames: isPublic
137 | ? await steam.getUserOwnedGamesOptional(steamId.getSteamID64()).catch(logError)
138 | : undefined,
139 | recentGames: isPublic
140 | ? await steam.getUserRecentGames(steamId.getSteamID64()).catch(logError)
141 | : undefined,
142 | badges: isPublic
143 | ? await steam.getUserBadges(steamId.getSteamID64()).catch(logError)
144 | : undefined,
145 | friends: isPublic
146 | ? await steam.getUserFriendSummaries(steamId.getSteamID64()).catch(logError)
147 | : undefined,
148 | inventory: await inventory.getInventoryWithValue(steamId.getSteamID64()).catch(logError),
149 | playerBans: playerBan,
150 | csgoStatsPlayer: await scraper.getPlayer(steamId.getSteamID64()).catch(logError),
151 | csgoStatsDeepPlayedWith: await getDeepPlayedWith(steamId.getSteamID64(), scraper).catch(
152 | logError
153 | ),
154 | steamReputation: await steamrep.getReputation(steamId.getSteamID64()).catch(logError),
155 | faceit: await faceIt.getFaceitData(steamId.getSteamID64()).catch(logError),
156 | };
157 | });
158 | return playerDataPromises;
159 | };
160 |
161 | export const getPlayerData = async (resolvableId: string): Promise => {
162 | const steamId64 = await steam.resolve(resolvableId);
163 | const steamId = new SteamID(steamId64);
164 | const [playerData] = await getPlayersData([steamId]);
165 | return playerData;
166 | };
167 |
--------------------------------------------------------------------------------
/src/gather/inventory.ts:
--------------------------------------------------------------------------------
1 | import { EconItem, Inventory } from 'steamcommunity-inventory';
2 | import axios from 'axios';
3 | import { getCache } from '../common/util';
4 |
5 | export interface ItemWithValue {
6 | marketName: string;
7 | price?: number;
8 | }
9 |
10 | export interface InventoryWithValue {
11 | collectibles: EconItem[];
12 | marketableItems: ItemWithValue[];
13 | }
14 |
15 | export interface CSGOTraderPrice {
16 | steam: {
17 | last_24h: number;
18 | last_7d: number;
19 | last_30d: number;
20 | last_90d: number;
21 | };
22 | }
23 | export interface PriceCache {
24 | [marketHashName: string]: CSGOTraderPrice;
25 | }
26 |
27 | export class InventoryValueCache {
28 | private namespace = `inventory-value`;
29 |
30 | private ttl = 1000 * 60 * 60 * 24 * 7; // 1 week
31 |
32 | private cache = getCache();
33 |
34 | private inventory: Inventory;
35 |
36 | private priceCache: PriceCache | undefined;
37 |
38 | constructor() {
39 | this.inventory = new Inventory({
40 | method: 'new',
41 | maxConcurent: 1,
42 | minTime: 500,
43 | });
44 | }
45 |
46 | private async getItemPrice(marketHashName: string): Promise {
47 | const cacheKey = `csgo-prices:market-price`;
48 | const ttl = 1000 * 60 * 60 * 24; // 1 day
49 |
50 | if (!this.priceCache) {
51 | const data = await this.cache.get(cacheKey, {});
52 | if (data) {
53 | this.priceCache = data;
54 | } else {
55 | // https://csgotrader.app/prices/
56 | const prices = await axios.get(
57 | 'https://prices.csgotrader.app/latest/prices_v6.json'
58 | );
59 | this.priceCache = prices.data;
60 | await this.cache.set(cacheKey, this.priceCache, ttl);
61 | }
62 | }
63 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
64 | return this.priceCache![marketHashName]?.steam.last_24h;
65 | }
66 |
67 | public async getInventoryWithValue(steamId64: string): Promise {
68 | const cacheKey = `${this.namespace}:inventory-${steamId64}`;
69 | const data = await this.cache.get(cacheKey);
70 | if (data === '') return undefined;
71 | if (data) return data;
72 | let inventory: InventoryWithValue | undefined;
73 | try {
74 | const items: EconItem[] = await this.inventory.get({
75 | appID: '730', // CSGO
76 | contextID: '2', // Default
77 | steamID: steamId64,
78 | });
79 | const marketableItems = items.filter((item) => item.marketable > 0);
80 | const collectibles = items.filter((item) =>
81 | item.tags.some((tag) => tag.internal_name === 'CSGO_Type_Collectible')
82 | );
83 | const marketableItemsWithPrices: ItemWithValue[] = await Promise.all(
84 | marketableItems.map(async (item) => ({
85 | price: await this.getItemPrice(item.market_hash_name),
86 | marketName: item.market_hash_name,
87 | }))
88 | );
89 | inventory = {
90 | marketableItems: marketableItemsWithPrices,
91 | collectibles,
92 | };
93 | } catch (err) {
94 | if (err.response?.status !== 403) {
95 | throw err;
96 | }
97 | inventory = undefined;
98 | }
99 | const cacheString = inventory || ''; // Cache undefined as empty string to prevent future API errors
100 | await this.cache.set(cacheKey, cacheString, this.ttl);
101 | return inventory;
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/gather/steamapi.ts:
--------------------------------------------------------------------------------
1 | import SteamAPI from 'steamapi';
2 | import { chunkArray, getCache } from '../common/util';
3 |
4 | export type FriendSummary = SteamAPI.Friend & SteamAPI.PlayerSummary & SteamAPI.PlayerBans;
5 |
6 | export class SteamApiCache extends SteamAPI {
7 | private namespace = `steamapi`;
8 |
9 | private ttl = 1000 * 60 * 60 * 24; // 1 day
10 |
11 | private cache = getCache();
12 |
13 | public async resolve(value: string): Promise {
14 | const cacheKey = `${this.namespace}:resolve-${value}`;
15 | const data = await this.cache.get(cacheKey);
16 | if (data) return data;
17 | const resp = await super.resolve(value);
18 | await this.cache.set(cacheKey, resp, this.ttl);
19 | return resp;
20 | }
21 |
22 | public async getUserLevel(id: string): Promise {
23 | const cacheKey = `${this.namespace}:user-level-${id}`;
24 | const data = await this.cache.get(cacheKey);
25 | if (data) return data;
26 | const resp = await super.getUserLevel(id);
27 | await this.cache.set(cacheKey, resp, this.ttl);
28 | return resp;
29 | }
30 |
31 | public async getUserOwnedGamesOptional(id: string): Promise {
32 | const cacheKey = `${this.namespace}:user-owned-games-${id}`;
33 | const data = await this.cache.get(cacheKey);
34 | if (data === '') return undefined;
35 | if (data) return data;
36 | let games: SteamAPI.Game[] | undefined;
37 | try {
38 | games = await super.getUserOwnedGames(id);
39 | } catch (err) {
40 | if (err.message !== 'No games found') {
41 | throw err;
42 | }
43 | games = undefined;
44 | }
45 | const cacheString = games || ''; // Cache undefined as empty string to prevent future API errors
46 | await this.cache.set(cacheKey, cacheString, this.ttl);
47 | return games;
48 | }
49 |
50 | public async getUserBadges(id: string): Promise {
51 | const cacheKey = `${this.namespace}:user-badges-${id}`;
52 | const data = await this.cache.get(cacheKey);
53 | if (data) return data;
54 | const resp = await super.getUserBadges(id);
55 | await this.cache.set(cacheKey, resp, this.ttl);
56 | return resp;
57 | }
58 |
59 | public async getUserRecentGames(id: string): Promise {
60 | const cacheKey = `${this.namespace}:user-recent-games-${id}`;
61 | const data = await this.cache.get(cacheKey);
62 | if (data) return data;
63 | const resp = await super.getUserRecentGames(id);
64 | await this.cache.set(cacheKey, resp, this.ttl);
65 | return resp;
66 | }
67 |
68 | public async getUserFriends(id: string): Promise {
69 | const cacheKey = `${this.namespace}:user-friends-${id}`;
70 | const data = await this.cache.get(cacheKey);
71 | if (data) return data;
72 | let resp: SteamAPI.Friend[];
73 | try {
74 | resp = await super.getUserFriends(id);
75 | } catch (err) {
76 | if (err.message !== 'Unauthorized') {
77 | throw err;
78 | }
79 | resp = []; // Cache undefined as empty array to prevent future API errors
80 | }
81 | await this.cache.set(cacheKey, resp, this.ttl);
82 | return resp;
83 | }
84 |
85 | public async getUserFriendSummaries(id: string): Promise {
86 | const friends = await this.getUserFriends(id);
87 | const friendIds = friends.map((f) => f.steamID);
88 | const summaries = await this.getUserSummaryOrdered(friendIds);
89 | const bans = await this.getUserBansOrdered(friendIds);
90 | const friendSummaries = friends.map(
91 | (f, index): FriendSummary => ({
92 | ...f,
93 | ...summaries[index],
94 | ...bans[index],
95 | })
96 | );
97 | return friendSummaries;
98 | }
99 |
100 | public async getUserSummaryLimitless(ids: string[]): Promise {
101 | const idChunks = chunkArray(ids, 100); // Steam API only accepts up to 100
102 | const results = await Promise.all(
103 | idChunks.map((idChunk) =>
104 | super.getUserSummary(idChunk).catch((err) => {
105 | if (err.message !== 'No players found') throw err;
106 | })
107 | )
108 | );
109 | return results.filter((e): e is SteamAPI.PlayerSummary[] => !!e).flat();
110 | }
111 |
112 | public async getUserSummaryOrdered(ids: string[]): Promise {
113 | const cachePrefix = `${this.namespace}:user-summary-`;
114 | const uncachedIds: string[] = [];
115 | const cachedResults: (SteamAPI.PlayerSummary | undefined)[] = await Promise.all(
116 | ids.map(async (id) => {
117 | const cacheKey = `${cachePrefix}${id}`;
118 | const data = await this.cache.get(cacheKey);
119 | if (data) return data;
120 | uncachedIds.push(id);
121 | return undefined;
122 | })
123 | );
124 |
125 | if (!uncachedIds.length) {
126 | return cachedResults as SteamAPI.PlayerSummary[];
127 | }
128 | const resp = await this.getUserSummaryLimitless(uncachedIds);
129 | // Cache the new results
130 | await Promise.all(
131 | resp.map(async (summary) => {
132 | const cacheKey = `${cachePrefix}${summary.steamID}`;
133 | await this.cache.set(cacheKey, summary, this.ttl);
134 | })
135 | );
136 | // Recombine new and cached results
137 | const populatedResults = ids.map((id, index) => {
138 | const cachedResult = cachedResults[index];
139 | if (cachedResult) return cachedResult;
140 | return resp.find((summary) => summary.steamID === id);
141 | });
142 | return populatedResults as SteamAPI.PlayerSummary[];
143 | }
144 |
145 | public async getUserBansLimitless(ids: string[]): Promise {
146 | const idChunks = chunkArray(ids, 100); // Steam API only accepts up to 100
147 | const results = await Promise.all(
148 | idChunks.map((idChunk) =>
149 | super.getUserBans(idChunk).catch((err) => {
150 | if (err.message !== 'No players found') throw err;
151 | })
152 | )
153 | );
154 | return results.filter((e): e is SteamAPI.PlayerBans[] => !!e).flat();
155 | }
156 |
157 | public async getUserBansOrdered(ids: string[]): Promise {
158 | const cachePrefix = `${this.namespace}:user-bans-`;
159 | const uncachedIds: string[] = [];
160 | const cachedResults: (SteamAPI.PlayerBans | undefined)[] = await Promise.all(
161 | ids.map(async (id) => {
162 | const cacheKey = `${cachePrefix}${id}`;
163 | const data = await this.cache.get(cacheKey);
164 | if (data) return data;
165 | uncachedIds.push(id);
166 | return undefined;
167 | })
168 | );
169 |
170 | if (!uncachedIds.length) {
171 | return cachedResults as SteamAPI.PlayerBans[];
172 | }
173 | const resp = await this.getUserBansLimitless(uncachedIds);
174 | // Cache the new results
175 | await Promise.all(
176 | resp.map(async (bans) => {
177 | const cacheKey = `${cachePrefix}${bans.steamID}`;
178 | await this.cache.set(cacheKey, bans, this.ttl);
179 | })
180 | );
181 | // Recombine new and cached results
182 | const populatedResults = ids.map((id, index) => {
183 | const cachedResult = cachedResults[index];
184 | if (cachedResult) return cachedResult;
185 | return resp.find((bans) => bans.steamID === id);
186 | });
187 | return populatedResults as SteamAPI.PlayerBans[];
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/src/gather/steamrep.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { getCache } from '../common/util';
3 |
4 | export interface Reputation {
5 | full: string;
6 | summary: string; // "none" for normal users
7 | }
8 |
9 | export interface Flags {
10 | status: string;
11 | }
12 |
13 | export interface Steamrep {
14 | flags: Flags;
15 | steamID32: string;
16 | steamID64: string;
17 | steamrepurl: string;
18 | reputation: Reputation;
19 | }
20 |
21 | export interface ReputationResponse {
22 | steamrep: Steamrep;
23 | }
24 |
25 | export type ReputationSummary = 'none' | 'SCAMMER' | string;
26 |
27 | // Example scammer: https://steamrep.com/api/beta4/reputation/76561198260351049?json=1
28 | // Example normal: https://steamrep.com/api/beta4/reputation/76561197964105706?json=1
29 |
30 | export class SteamRepCache {
31 | private namespace = `steamrep`;
32 |
33 | private ttl = 1000 * 60 * 60 * 24 * 7 * 4; // 4 weeks
34 |
35 | private cache = getCache();
36 |
37 | public async getReputation(steamId64: string): Promise {
38 | const cacheKey = `${this.namespace}:reputation-${steamId64}`;
39 | const data = await this.cache.get(cacheKey);
40 | if (data) return data;
41 | const resp = await axios.get(
42 | `https://steamrep.com/api/beta4/reputation/${steamId64}?json=1`
43 | );
44 | const reputationSummary = resp.data.steamrep.reputation.summary;
45 | await this.cache.set(cacheKey, reputationSummary, this.ttl);
46 | return reputationSummary;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'source-map-support/register';
2 | // import fs from 'fs-extra';
3 | import 'dotenv/config';
4 | import './discord/bot';
5 | // import { getPlayersData } from './gather/index';
6 | // import { analyzePlayers } from './analyze';
7 |
8 | const status = `# 3 2 "Chuck Lubs" STEAM_1:1:19993736 07:55 31 0 active 786432
9 | # 6 5 "Snowball" STEAM_1:0:19019648 07:55 55 0 active 196608
10 | # 7 6 "baguette" STEAM_1:0:211200618 07:55 46 0 active 786432
11 | # 8 7 "BabyPluto403" STEAM_1:0:465744591 07:55 65 0 active 196608
12 | # 9 8 "Bill" STEAM_1:0:518085778 07:55 61 0 active 128000
13 | # 10 9 "Smutno" STEAM_1:1:95220551 07:55 48 0 active 196608
14 | # 11 10 "♥ 𝓑𝓮𝓮 ♥" STEAM_1:0:53041154 07:55 78 0 active 196608
15 | # 12 11 "jake" STEAM_1:0:156206114 07:55 134 0 active 196608
16 | # 13 12 "WORM" STEAM_1:0:30365447 07:55 32 0 active 786432
17 | # 14 13 "noreilly" STEAM_1:1:547419869 07:52 61 0 active 196608`;
18 |
19 | // getPlayersData(status)
20 | // .then((result) => {
21 | // fs.outputJSONSync('_gatherOutput.json', result, { spaces: 2 });
22 | // const analyzedPlayers = analyzePlayers(result);
23 | // fs.outputJSONSync('_analyzeOutput.json', analyzedPlayers, { spaces: 2 });
24 | // console.log('complete');
25 | // })
26 | // .catch((err) => console.error(err));
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2021", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
5 | "moduleResolution": "node",
6 | "outDir": "./dist", /* Redirect output structure to the directory. */
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "typeRoots": [
10 | "node_modules/@types",
11 | "types"
12 | ],
13 | "inlineSourceMap": true, // Inline sourcemaps for jest debugging
14 | "useUnknownInCatchVariables": false,
15 | "skipLibCheck": true, // awaited-dom has compile issues
16 | },
17 | "exclude": [
18 | "node_modules",
19 | "dist",
20 | ]
21 | }
--------------------------------------------------------------------------------