├── .dockerignore ├── .eslintrc.cjs ├── .github └── workflows │ └── build-and-push-image.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.cjs ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── Readme.md ├── entrypoint.sh ├── package-lock.json ├── package.json ├── src ├── config.ts ├── demo-log.ts ├── download.ts ├── entrypoint-config.ts ├── gcpd.ts ├── index.ts ├── logger.ts ├── match-history.ts ├── steam-gc.ts ├── steam.ts └── store.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | demos 3 | config 4 | .*.cjs -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es2023: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | 'airbnb-typescript/base', 10 | 'plugin:prettier/recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:promise/recommended', 13 | 'prettier', 14 | ], 15 | parser: '@typescript-eslint/parser', 16 | parserOptions: { 17 | project: './tsconfig.json', 18 | }, 19 | ignorePatterns: ['dist/', 'node_modules/', '!.*.cjs'], 20 | }; 21 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push-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@v4 15 | 16 | - name: Docker meta 17 | id: meta 18 | uses: docker/metadata-action@v5 19 | with: 20 | images: ghcr.io/${{ github.repository }} 21 | tags: | 22 | type=raw,value=latest,enable={{is_default_branch}} 23 | 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | 27 | - name: Set up Docker Buildx 28 | id: buildx 29 | uses: docker/setup-buildx-action@v3 30 | with: 31 | install: true 32 | 33 | - name: Login to GitHub Container Registry 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.repository_owner }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Build and push 41 | uses: docker/build-push-action@v5 42 | with: 43 | target: deploy 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | platforms: linux/amd64,linux/arm64 47 | cache-from: type=gha,scope=${{ github.workflow }} 48 | cache-to: type=gha,mode=max,scope=${{ github.workflow }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 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 variable files 77 | .env 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | .env.local 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | ### Node Patch ### 133 | # Serverless Webpack directories 134 | .webpack/ 135 | 136 | # Optional stylelint cache 137 | 138 | # SvelteKit build / generate output 139 | .svelte-kit 140 | 141 | config 142 | demos -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /.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 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug Main", 11 | "runtimeArgs": [ 12 | "--loader", 13 | "tsx" 14 | ], 15 | "outputCapture": "std", 16 | "args": [ 17 | "${workspaceFolder}/src/index.ts" 18 | ] 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "typescript" 5 | ], 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit" 8 | }, 9 | "editor.formatOnSave": true, 10 | "[javascript]": { 11 | "editor.formatOnSave": false, 12 | }, 13 | "[typescript]": { 14 | "editor.formatOnSave": false, 15 | }, 16 | "eslint.enable": true, 17 | "typescript.tsdk": "node_modules/typescript/lib", 18 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ######## 2 | # BASE 3 | ######## 4 | FROM node:18-alpine as base 5 | 6 | WORKDIR /usr/app 7 | 8 | ######## 9 | # BUILD 10 | ######## 11 | FROM base as build 12 | 13 | # Copy all jsons 14 | COPY package*.json tsconfig.json ./ 15 | 16 | # Add dev deps 17 | RUN npm ci 18 | 19 | # Copy source code 20 | COPY src src 21 | 22 | RUN npm run build 23 | 24 | ######## 25 | # DEPLOY 26 | ######## 27 | FROM base as deploy 28 | 29 | RUN apk add --no-cache \ 30 | jq \ 31 | supercronic \ 32 | tini \ 33 | tzdata 34 | 35 | COPY entrypoint.sh /usr/local/bin/docker-entrypoint.sh 36 | # backwards compat entrypoint 37 | RUN ln -s /usr/local/bin/docker-entrypoint.sh / 38 | 39 | COPY package*.json ./ 40 | RUN npm ci --omit=dev 41 | 42 | # Steal compiled code from build image 43 | COPY --from=build /usr/app/dist dist 44 | 45 | USER node 46 | ENV NODE_ENV=production CONFIG_DIR=/config DEMOS_DIR=/demos 47 | 48 | VOLUME [ "/config" ] 49 | VOLUME [ "/demos" ] 50 | 51 | ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # cs-demo-downloader 2 | 3 | Automatically download Counter-Strike demos of Premier, competitive, and wingman matches. 4 | Downloads the demo and updates the modified date file metadata to the timestamp the match was played. 5 | Can use two strategies: 6 | 7 | - Logs into your Steam account and scrapes the [GCPD page](https://steamcommunity.com/my/gcpd/730) for any new demos. 8 | - Uses any Steam account and downloads the demos using a user's authorization code (like Leetify does). 9 | 10 | ## Config 11 | 12 | The config is written in a `config.json` file in the `/config` volume. 13 | 14 | ### Shared Properties 15 | 16 | - `logLevel`: `trace`, `debug`, `info`, `warn`, `error` - defaults to `info` 17 | - `cronSchedule`: a standard 5 digit cron expressions - defaults to `0 * * * *` (hourly) 18 | - `runOnStartup`: whether to run the job immedately on startup - defaults to `true` 19 | 20 | ### GCPD mode 21 | 22 | This is the easiest approach for a single user downloading their own demos. Multiple users can be put in the `gcpdLogins` array. 23 | 24 | - `gcpdLogins[].username`: your Steam login username 25 | - `gcpdLogins[].password`: your Steam login password 26 | - `gcpdLogins[].secret`: if applicable, your Steam Guard secret, as a base64 (28 characters) or hex string. This is labeled as `shared_secret` in a [Steam Desktop Authenticator](https://github.com/Jessecar96/SteamDesktopAuthenticator) `.maFile` 27 | 28 | ```json 29 | { 30 | "gcpdLogins": [ 31 | { 32 | "username": "steamusername", 33 | "password": "steampassword", 34 | "secret": "yeBrc0jD9Ff0kjKOx8+hnckVojg=" 35 | } 36 | ], 37 | 38 | "logLevel": "info", 39 | "runOnStartup": false, 40 | "cronSchedule": "0 * * * *" 41 | } 42 | ``` 43 | 44 | ### Auth Code Mode 45 | 46 | This approach is best if you want to download demos for yourself and others that won't give you their login details. It requires an account to briefly "launch" CS2 and fetch match data from the Game Coordinator, so you should have a secondary bot account for this approach. 47 | 48 | - `authCodeLogin.username`: your bot account's Steam login username 49 | - `authCodeLogin.password`: your bot account's Steam login password 50 | - `authCodeLogin.secret`: if applicable, your Steam Guard secret, as a base64 (28 characters) or hex string. This is labeled as `shared_secret` in a [Steam Desktop Authenticator](https://github.com/Jessecar96/SteamDesktopAuthenticator) `.maFile` 51 | - `steamApiKey`: the [Steam API Key](https://steamcommunity.com/dev/apikey) of your bot account 52 | - `authCodes[].authCode`: the [match history authentication code](https://help.steampowered.com/en/wizard/HelpWithGameIssue/?appid=730&issueid=128) for a user 53 | - `authCodes[].steamId64`: the SteamID64 for the user, as a string 54 | - `authCodes[].oldestShareCode`: the oldest match share code available in the user's match history 55 | 56 | ```json 57 | { 58 | "authCodes": [ 59 | { 60 | "authCode": "ABCD-1234-WXYZ", 61 | "steamId64": "70000000000000000", 62 | "oldestShareCode": "CSGO-aBcdE-aBcdE-aBcdE-aBcdE-aBcdE" 63 | } 64 | ], 65 | "authCodeLogin": { 66 | "username": "steamusername", 67 | "password": "steampassword", 68 | "secret": "yeBrc0jD9Ff0kjKOx8+hnckVojg=" 69 | }, 70 | "steamApiKey": "AAAABBBBCCCCDDDD1111222233334444", 71 | 72 | "logLevel": "info", 73 | "runOnStartup": false, 74 | "cronSchedule": "0 * * * *" 75 | } 76 | ``` 77 | 78 | ## Docker 79 | 80 | ### Image 81 | 82 | `ghcr.io/claabs/cs-demo-downloader:latest` 83 | 84 | ### Volumes 85 | 86 | - `/config`: Where the config file is stored, and where the "database" store is written 87 | - `/demos`: Where the decompressed demos are stored 88 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | TEMP_CONFIG="/tmp/config.json" 6 | 7 | # Resolve and output the below variables to /tmp/config.json 8 | node /usr/app/dist/src/entrypoint-config.js 9 | export TZ=$(cat $TEMP_CONFIG | jq -r ".timezone") 10 | RUN_ON_STARTUP=$(cat $TEMP_CONFIG | jq -r ".runOnStartup") 11 | RUN_ONCE=$(cat $TEMP_CONFIG | jq -r ".runOnce") 12 | CRON_SCHEDULE=$(cat $TEMP_CONFIG | jq -r ".cronSchedule") 13 | 14 | # If runOnStartup is set, run it once before setting up the schedule 15 | echo "Run on startup: ${RUN_ON_STARTUP}" 16 | if [ "$RUN_ON_STARTUP" = "true" ]; then 17 | node /usr/app/dist/src/index.js 18 | fi 19 | 20 | # If runOnce is not set, schedule the process 21 | echo "Run once: ${RUN_ONCE}" 22 | if [ "$RUN_ONCE" = "false" ]; then 23 | echo "Setting cron schedule as ${CRON_SCHEDULE}" 24 | # Add the command to the crontab 25 | echo "${CRON_SCHEDULE} node /usr/app/dist/src/index.js" > $HOME/crontab 26 | # Run the cron process. The container should halt here and wait for the schedule. 27 | supercronic -passthrough-logs $HOME/crontab 28 | fi 29 | echo "Exiting..." 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cs-demo-downloader", 3 | "version": "1.1.0", 4 | "description": "Automatically download Counter-Strike demos of ranked, unranked, and wingman matches", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node --loader tsx src/index.ts", 9 | "build": "rimraf dist && tsc", 10 | "lint": "tsc --noEmit && eslint .", 11 | "docker:build": "docker build . -t cs-demo-downloader:latest --target deploy", 12 | "docker:run": "docker run --rm -ti -v $(pwd)/config:/config -v $(pwd)/demos:/demos cs-demo-downloader:latest" 13 | }, 14 | "author": "", 15 | "license": "MIT", 16 | "dependencies": { 17 | "axios": "^1.6.2", 18 | "csgo-sharecode": "^3.1.1", 19 | "fs-extra": "^11.2.0", 20 | "globaloffensive": "^3.0.0", 21 | "jsdom": "^23.0.1", 22 | "p-queue": "^7.4.1", 23 | "p-timeout": "^6.1.2", 24 | "pino": "^8.16.2", 25 | "pino-pretty": "^10.2.3", 26 | "steam-session": "^1.7.1", 27 | "steam-totp": "^2.1.2", 28 | "steam-user": "^5.0.4", 29 | "unbzip2-stream": "^1.4.3" 30 | }, 31 | "devDependencies": { 32 | "@tsconfig/node18": "^18.2.2", 33 | "@tsconfig/strictest": "^2.0.2", 34 | "@types/fs-extra": "^11.0.4", 35 | "@types/globaloffensive": "^2.3.4", 36 | "@types/jsdom": "^21.1.6", 37 | "@types/node": "^18.19.0", 38 | "@types/steam-totp": "^2.1.2", 39 | "@types/steam-user": "^4.26.8", 40 | "@types/unbzip2-stream": "^1.4.3", 41 | "@typescript-eslint/eslint-plugin": "^6.13.1", 42 | "@typescript-eslint/parser": "^6.13.1", 43 | "eslint": "^8.54.0", 44 | "eslint-config-airbnb-base": "^15.0.0", 45 | "eslint-config-airbnb-typescript": "^17.1.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "eslint-plugin-import": "^2.29.0", 48 | "eslint-plugin-prettier": "^5.0.1", 49 | "eslint-plugin-promise": "^6.1.1", 50 | "prettier": "^3.1.0", 51 | "rimraf": "^5.0.5", 52 | "tsx": "^4.6.1", 53 | "typescript": "^5.3.2" 54 | }, 55 | "overrides": { 56 | "tsconfig-paths": "^4.0.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { readJSONSync } from 'fs-extra/esm'; 2 | import path from 'node:path'; 3 | 4 | export interface Config { 5 | authCodes?: AuthCodeUser[]; 6 | gcpdLogins?: LoginCredential[]; 7 | authCodeLogin?: LoginCredential; 8 | steamApiKey?: string; 9 | logLevel?: string; 10 | runOnStartup?: boolean; 11 | runOnce?: boolean; 12 | cronSchedule?: string; 13 | timezone?: string; 14 | } 15 | 16 | export interface AuthCodeUser { 17 | authCode: string; 18 | steamId64: string; 19 | oldestShareCode: string; 20 | } 21 | 22 | export interface LoginCredential { 23 | username: string; 24 | password: string; 25 | secret: string; 26 | } 27 | 28 | const configDir = process.env['CONFIG_DIR'] || 'config'; 29 | const configFile = path.join(configDir, 'config.json'); 30 | 31 | export const config = readJSONSync(configFile, 'utf-8') as Config; 32 | -------------------------------------------------------------------------------- /src/demo-log.ts: -------------------------------------------------------------------------------- 1 | import fsx from 'fs-extra/esm'; 2 | import path from 'node:path'; 3 | import L from './logger.js'; 4 | import { DownloadableMatch } from './download.js'; 5 | 6 | const configDir = process.env['CONFIG_DIR'] || 'config'; 7 | const logFile = path.join(configDir, 'demo-log.csv'); 8 | 9 | // eslint-disable-next-line import/prefer-default-export 10 | export const appendDemoLog = async (matches: DownloadableMatch[]): Promise => { 11 | L.trace({ matchesLength: matches.length, logFile }, 'Writing matches to demo log'); 12 | const logLines = matches.reduce( 13 | (lines, match) => `${lines}${match.date.toISOString()}\t${match.type || ''}\t${match.url}\n`, 14 | '', 15 | ); 16 | try { 17 | await fsx.outputFile(logFile, logLines, { encoding: 'utf-8', flag: 'a' }); 18 | } catch (err) { 19 | L.error({ err }, 'Error writing to demo log'); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/download.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import bz2 from 'unbzip2-stream'; 3 | import fs from 'node:fs'; 4 | import fsp from 'fs/promises'; 5 | import fsx from 'fs-extra'; 6 | import util from 'node:util'; 7 | import stream from 'node:stream'; 8 | import path from 'node:path'; 9 | import L from './logger.js'; 10 | 11 | export interface DownloadableMatch { 12 | date: Date; 13 | url?: string; 14 | matchId: bigint; 15 | type?: string; 16 | } 17 | 18 | const pipeline = util.promisify(stream.pipeline); 19 | const demosDir = process.env['DEMOS_DIR'] || 'demos'; 20 | const tempDemosDir = path.join(demosDir, 'temp'); 21 | 22 | export const gcpdUrlToFilename = (url: string, suffix?: string): string => { 23 | // http://replay129.valve.net/730/003638895521671676017_1102521424.dem.bz2 24 | // match730_003617919461891244205_1406239579_129.dem 25 | 26 | const matchGroups = url.match(/^https?:\/\/replay(\d+)\.valve\.net\/(\d+)\/(\d+_\d+)\.dem\.bz2$/); 27 | if (!matchGroups) throw new Error(`Invalid GCPD URL: ${url}`); 28 | const [, regionId, gameId, matchId] = matchGroups; 29 | return `match${gameId}_${matchId}_${regionId}${suffix ? `_${suffix}` : ''}.dem`; 30 | }; 31 | 32 | /** 33 | * Downloads, extracts, and updates modified date of demo 34 | * @param match Match metadata 35 | * @returns matchId if match failed 36 | */ 37 | export const downloadSaveDemo = async (match: DownloadableMatch): Promise => { 38 | try { 39 | if (!match.url) throw new Error('Match download URL missing'); 40 | 41 | await fsx.mkdirp(tempDemosDir); 42 | const tempFilename = path.join(tempDemosDir, gcpdUrlToFilename(match.url, match.type)); 43 | 44 | await fsx.mkdirp(demosDir); // redundant, but added in-case the temp directory is changed in the future to not be nested within the demos directory 45 | const completedFilename = path.join(demosDir, gcpdUrlToFilename(match.url, match.type)); 46 | 47 | const exists = await fsx.exists(completedFilename); 48 | if (!exists) { 49 | L.trace({ url: match.url }, 'Downloading demo'); 50 | const resp = await axios.get(match.url, { responseType: 'stream' }); 51 | L.trace({ url: match.url }, 'Demo download complete'); 52 | await pipeline(resp.data, bz2(), fs.createWriteStream(tempFilename, 'binary')); 53 | L.trace({ filename: tempFilename }, 'Demo saved to file'); 54 | await fsp.rename(tempFilename, completedFilename); 55 | await fsp.utimes(completedFilename, match.date, match.date); 56 | L.info({ filename: completedFilename, date: match.date }, 'Demo save complete'); 57 | } else { 58 | L.info({ filename: completedFilename }, 'File already exists, skipping download'); 59 | } 60 | return null; 61 | } catch (err) { 62 | L.error({ err, match }, 'Error downloading GCPD demo'); 63 | return match.matchId; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/entrypoint-config.ts: -------------------------------------------------------------------------------- 1 | import { outputJSONSync } from 'fs-extra/esm'; 2 | import { config } from './config.js'; 3 | import L from './logger.js'; 4 | 5 | try { 6 | const output = { 7 | runOnStartup: config.runOnStartup ?? true, 8 | runOnce: config.runOnce ?? false, 9 | cronSchedule: config.cronSchedule ?? '0 * * * *', 10 | timezone: config.timezone ?? process.env.TZ ?? 'UTC', 11 | }; 12 | outputJSONSync('/tmp/config.json', output); 13 | } catch (err) { 14 | L.error(err); 15 | } 16 | -------------------------------------------------------------------------------- /src/gcpd.ts: -------------------------------------------------------------------------------- 1 | import axios, { type AxiosResponse } from 'axios'; 2 | import { JSDOM } from 'jsdom'; 3 | import PQueue from 'p-queue'; 4 | import type { Logger } from 'pino'; 5 | import { loginSteamWeb } from './steam.js'; 6 | import { getStoreValue } from './store.js'; 7 | import type { LoginCredential } from './config.js'; 8 | import logger from './logger.js'; 9 | import { DownloadableMatch } from './download.js'; 10 | 11 | export interface ParseListResult { 12 | newMatches: DownloadableMatch[]; 13 | finished: boolean; 14 | } 15 | 16 | export interface ContinueResponse { 17 | success: boolean; 18 | html: string; 19 | continue_token?: string; 20 | continue_text?: string; 21 | } 22 | 23 | const userAgent = 24 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36'; 25 | 26 | const tabToType = (tab: string): string | undefined => { 27 | const convertMap: Record = { 28 | matchhistorycompetitive: 'ranked', 29 | matchhistorypremier: 'premier', 30 | matchhistoryscrimmage: 'unranked', 31 | matchhistorywingman: 'wingman', 32 | matchhistorycompetitivepermap: 'competitive', 33 | }; 34 | return convertMap[tab]; 35 | }; 36 | 37 | const parseCsgoMatchList = ( 38 | html: string, 39 | type?: string, 40 | minMatchIdBound?: bigint, 41 | ): ParseListResult => { 42 | const dom = new JSDOM(html).window; 43 | // inspired by https://github.com/leetify/leetify-gcpd-upload/blob/main/src/offscreen/dom-parser.ts 44 | const cells = dom.document.querySelectorAll('td.val_left'); 45 | 46 | const matches: DownloadableMatch[] = []; 47 | let finished = false; 48 | // eslint-disable-next-line no-restricted-syntax 49 | for (const matchCell of cells) { 50 | const urlElement = matchCell.querySelector( 51 | 'table.csgo_scoreboard_inner_left tbody tr td a', 52 | ) as HTMLLinkElement; 53 | if (!urlElement) { 54 | // when a match does not have a download url, all later matches will most likely not have one either 55 | finished = true; 56 | break; 57 | } 58 | 59 | const url = urlElement.getAttribute('href'); 60 | const urlMatch = url?.match(/^https?:\/\/replay\d+\.valve\.net\/730\/(\d+)_\d+\.dem\.bz2$/); 61 | if (!url || !urlMatch) break; // something is weird if this happens 62 | const matchIdStr = urlMatch.at(1); 63 | if (!matchIdStr) break; 64 | const matchId = BigInt(matchIdStr); 65 | if (minMatchIdBound && matchId <= minMatchIdBound) { 66 | // if this match is older or as old as the latest match we've found previously, we don't need to upload it (or any following matches) 67 | finished = true; 68 | break; 69 | } 70 | 71 | const dateElement = matchCell.querySelector( 72 | 'table.csgo_scoreboard_inner_left tbody tr:nth-child(2) td', 73 | ) as HTMLTableCellElement; 74 | const dateText = dateElement?.innerHTML?.trim(); 75 | if (!dateText?.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} GMT$/)) break; // something is weird if this happens 76 | const date = new Date(dateText); 77 | matches.push({ date, url, matchId, type }); 78 | } 79 | return { newMatches: matches, finished }; 80 | }; 81 | 82 | export const getTabMatches = async ( 83 | cookies: string[], 84 | tab: string, 85 | log: Logger, 86 | minContinueToken?: bigint, 87 | gameId = 730, 88 | ): Promise => { 89 | const L = log.child({ tab }); 90 | L.debug({ gameId, minContinueToken }, 'Getting match tab data'); 91 | const initResp = await axios.get(`https://steamcommunity.com/my/gcpd/${gameId}`, { 92 | params: { 93 | tab, 94 | }, 95 | responseType: 'document', 96 | headers: { 97 | Cookie: cookies, 98 | 'User-Agent': userAgent, 99 | }, 100 | }); 101 | 102 | const continueTokenMatch = initResp.data.match(/g_sGcContinueToken = '(\d+)'/); 103 | if (!continueTokenMatch) throw new Error('Could not get document continue token'); 104 | let continueToken = continueTokenMatch.at(1); 105 | 106 | const sessionIdMatch = initResp.data.match(/g_sessionID = "([0-9a-f]{24})"/); 107 | if (!sessionIdMatch) throw new Error('Could not get document session ID'); 108 | const sessionId = sessionIdMatch.at(1); 109 | if (!sessionId) throw new Error('Could not get document session ID match group'); 110 | 111 | const type = tabToType(tab); 112 | const initParseResult = parseCsgoMatchList(initResp.data, type, minContinueToken); 113 | let { finished } = initParseResult; 114 | const parsedMatches = initParseResult.newMatches; 115 | L.debug({ parsedMatchesLength: parsedMatches.length }, 'Parsed init matches'); 116 | 117 | L.trace({ finished, continueToken, minContinueToken }, 'Attempting to get continued match lists'); 118 | while ( 119 | !finished && 120 | continueToken && 121 | !(minContinueToken && BigInt(continueToken) < minContinueToken) 122 | ) { 123 | // fix with some async iterator?? 124 | // eslint-disable-next-line no-await-in-loop 125 | const continueResp = (await axios.get( 126 | `https://steamcommunity.com/my/gcpd/${gameId}`, 127 | { 128 | params: { 129 | tab, 130 | continue_token: continueToken, 131 | sessionid: sessionId, 132 | ajax: 1, 133 | }, 134 | responseType: 'json', 135 | headers: { 136 | Cookie: cookies, 137 | 'User-Agent': userAgent, 138 | }, 139 | }, 140 | )) as AxiosResponse; 141 | 142 | continueToken = continueResp.data.continue_token; 143 | 144 | const continueParseResult = parseCsgoMatchList(continueResp.data.html, type, minContinueToken); 145 | L.debug( 146 | { parsedMatchesLength: continueParseResult.newMatches.length }, 147 | 'Parsed continue matches', 148 | ); 149 | 150 | finished = continueParseResult.finished; 151 | parsedMatches.push(...continueParseResult.newMatches); 152 | L.trace( 153 | { finished, continueToken, minContinueToken }, 154 | 'Attempting to get more continued match lists', 155 | ); 156 | } 157 | return parsedMatches; 158 | }; 159 | 160 | export const getMatches = async (userLogin: LoginCredential): Promise => { 161 | const L = logger.child({ username: userLogin.username }); 162 | const cookies = await loginSteamWeb(userLogin); 163 | const minContinueTokenStr = await getStoreValue('lastContinueToken', userLogin.username); 164 | const minContinueToken = minContinueTokenStr ? BigInt(minContinueTokenStr) : undefined; 165 | 166 | const tabs = [ 167 | 'matchhistorycompetitive', // Deprecated? 168 | 'matchhistorypremier', 169 | 'matchhistoryscrimmage', // Deprecated? 170 | 'matchhistorywingman', 171 | 'matchhistorycompetitivepermap', 172 | ]; 173 | const queue = new PQueue({ concurrency: 1 }); 174 | const newDemos = ( 175 | await Promise.all( 176 | tabs.map((tab) => 177 | queue.add(() => getTabMatches(cookies, tab, L, minContinueToken), { throwOnTimeout: true }), 178 | ), 179 | ) 180 | ).flat(); 181 | 182 | return newDemos; 183 | }; 184 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import PQueue from 'p-queue'; 2 | import { downloadSaveDemo } from './download.js'; 3 | import { getMatches } from './gcpd.js'; 4 | import type { LoginCredential } from './config.js'; 5 | import { config } from './config.js'; 6 | import { setStoreValue } from './store.js'; 7 | import logger from './logger.js'; 8 | import { appendDemoLog } from './demo-log.js'; 9 | import { getAllUsersMatches } from './steam-gc.js'; 10 | 11 | const handleGcpdUser = async (user: LoginCredential, gcpdQueue: PQueue, downloadQueue: PQueue) => { 12 | const L = logger.child({ username: user.username }); 13 | const matches = await gcpdQueue.add(() => getMatches(user), { throwOnTimeout: true }); 14 | if (!matches.length) { 15 | L.info('No new GCPD matches found'); 16 | return; 17 | } 18 | 19 | const demoUrls = matches.map((match) => match.url); 20 | L.info({ matchCount: matches.length, demoUrls }, 'New GCPD matches found'); 21 | L.trace({ matches }, 'New GCPD match details'); 22 | await appendDemoLog(matches); 23 | const downloadResults = await Promise.all( 24 | matches.map((match) => 25 | downloadQueue.add(() => downloadSaveDemo(match), { throwOnTimeout: true }), 26 | ), 27 | ); 28 | const failedDownloads = downloadResults.filter((id): id is bigint => id !== null).sort(); 29 | const firstFailedMatch = failedDownloads.at(0); 30 | // Set the latest match ID for a future run 31 | const greatestMatchId = matches.reduce((greatestId, match) => { 32 | // If is the greatest ID found, and is not greater than the earliest failed ID 33 | if ( 34 | !greatestId || 35 | (match.matchId > greatestId && !(firstFailedMatch && match.matchId >= firstFailedMatch)) 36 | ) { 37 | return match.matchId; 38 | } 39 | return greatestId; 40 | }, BigInt(0)); 41 | if (greatestMatchId) { 42 | setStoreValue('lastContinueToken', user.username, greatestMatchId.toString()); 43 | } 44 | }; 45 | 46 | const main = async () => { 47 | logger.debug({ config }, 'Starting cs-demo-downloader'); 48 | const gcpdQueue = new PQueue({ concurrency: 1, throwOnTimeout: true }); 49 | const downloadQueue = new PQueue({ concurrency: 5, throwOnTimeout: true }); 50 | 51 | if (config.gcpdLogins?.length) { 52 | await Promise.all( 53 | config.gcpdLogins.map((user) => handleGcpdUser(user, gcpdQueue, downloadQueue)), 54 | ); 55 | } 56 | 57 | if (config.authCodeLogin && config.steamApiKey && config.authCodes?.length) { 58 | await getAllUsersMatches(config.authCodes, downloadQueue); 59 | } 60 | }; 61 | 62 | main().catch((err) => { 63 | logger.error(err); 64 | }); 65 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { pino } from 'pino'; 2 | import { config } from './config.js'; 3 | 4 | const logger = pino({ 5 | transport: { 6 | target: 'pino-pretty', 7 | options: { 8 | translateTime: `SYS:standard`, 9 | }, 10 | }, 11 | redact: ['config.users[*].password', 'config.users[*].secret'], 12 | formatters: { 13 | level: (label) => { 14 | return { level: label }; 15 | }, 16 | }, 17 | level: config.logLevel, 18 | base: undefined, 19 | }); 20 | 21 | export default logger; 22 | -------------------------------------------------------------------------------- /src/match-history.ts: -------------------------------------------------------------------------------- 1 | // https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1?key=XXX&steamid=765XXX&steamidkey=AAAA-AAAAA-AAAA&knowncode=CSGO-ZT42K-Jxxxx-Kxxxx-5xxxx-Oixxx 2 | import axios from 'axios'; 3 | import PQueue from 'p-queue'; 4 | import { config } from './config.js'; 5 | import L from './logger.js'; 6 | 7 | export interface MatchHistoryResponse { 8 | result: { 9 | nextcode: string; 10 | }; 11 | } 12 | 13 | const getNextMatchCode = async ( 14 | steamId: string, 15 | authCode: string, 16 | lastShareCode: string, 17 | shareCodeQueue: PQueue, 18 | ): Promise => { 19 | if (!config.steamApiKey) throw new Error('Need Steam API key to fetch match history'); 20 | 21 | const resp = await shareCodeQueue.add( 22 | async () => { 23 | L.trace({ lastShareCode }, 'Fetching next share code'); 24 | return axios.get( 25 | 'https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1', 26 | { 27 | params: { 28 | key: config.steamApiKey, 29 | steamid: steamId, 30 | steamidkey: authCode, 31 | knowncode: lastShareCode, 32 | }, 33 | }, 34 | ); 35 | }, 36 | { throwOnTimeout: true }, 37 | ); 38 | return resp.data.result.nextcode; 39 | }; 40 | 41 | export const getAllNewMatchCodes = async ( 42 | steamId: string, 43 | authCode: string, 44 | inLastShareCode: string, 45 | shareCodeQueue: PQueue, 46 | ): Promise => { 47 | const shareCodes: string[] = []; 48 | let lastShareCode = await getNextMatchCode(steamId, authCode, inLastShareCode, shareCodeQueue); 49 | 50 | while (lastShareCode && lastShareCode !== 'n/a') { 51 | shareCodes.push(lastShareCode); 52 | // eslint-disable-next-line no-await-in-loop 53 | lastShareCode = await getNextMatchCode(steamId, authCode, lastShareCode, shareCodeQueue); 54 | } 55 | return shareCodes; 56 | }; 57 | -------------------------------------------------------------------------------- /src/steam-gc.ts: -------------------------------------------------------------------------------- 1 | import GlobalOffensive from 'globaloffensive'; 2 | import PQueue from 'p-queue'; 3 | import { decodeMatchShareCode } from 'csgo-sharecode'; 4 | import promiseTimeout from 'p-timeout'; 5 | import { config, type AuthCodeUser } from './config.js'; 6 | import logger from './logger.js'; 7 | import { loginSteamClient } from './steam.js'; 8 | import { getAllNewMatchCodes } from './match-history.js'; 9 | import { getStoreValue, setStoreValue } from './store.js'; 10 | import { DownloadableMatch, downloadSaveDemo } from './download.js'; 11 | import { appendDemoLog } from './demo-log.js'; 12 | 13 | export interface MatchIdentifier { 14 | shareCode: string; 15 | matchId: bigint; 16 | steamId: string; 17 | } 18 | 19 | export interface MatchIdUrl { 20 | url: string; 21 | matchId: string; 22 | } 23 | 24 | type MatchRespFn = (match: GlobalOffensive.Match) => void; 25 | 26 | export const getUserShareCodes = async ( 27 | user: AuthCodeUser, 28 | shareCodesQueue: PQueue, 29 | ): Promise => { 30 | const { steamId64, authCode } = user; 31 | const L = logger.child({ steamId: steamId64 }); 32 | try { 33 | const storeShareCode = await getStoreValue('lastShareCode', steamId64); 34 | const lastShareCode = storeShareCode ?? user.oldestShareCode; 35 | if (!lastShareCode) throw new Error('No share code found'); 36 | L.debug({ lastShareCode }, 'Getting new share codes'); 37 | const shareCodes = await getAllNewMatchCodes( 38 | steamId64, 39 | authCode, 40 | lastShareCode, 41 | shareCodesQueue, 42 | ); 43 | if (!storeShareCode) { 44 | shareCodes.unshift(lastShareCode); 45 | } 46 | return shareCodes.map((shareCode) => { 47 | const { matchId } = decodeMatchShareCode(shareCode); 48 | return { shareCode, steamId: steamId64, matchId }; 49 | }); 50 | } catch (err) { 51 | L.error({ err }); 52 | return []; 53 | } 54 | }; 55 | 56 | export const getAllUsersMatches = async ( 57 | users: AuthCodeUser[], 58 | downloadQueue: PQueue, 59 | ): Promise => { 60 | if (!config.authCodeLogin) throw new Error('Missing auth code login credentials'); 61 | const L = logger.child({ username: config.authCodeLogin.username }); 62 | const shareCodesQueue = new PQueue({ concurrency: 1, interval: 1500, intervalCap: 1 }); 63 | const usersShareCodeIds = await Promise.all( 64 | users.map(async (user) => getUserShareCodes(user, shareCodesQueue)), 65 | ); 66 | const shareCodes = Array.from(new Set(usersShareCodeIds.flat().map((id) => id.shareCode))); 67 | 68 | // Do nothing if no codes 69 | if (!shareCodes.length) { 70 | L.info('No new matches to download'); 71 | return; 72 | } 73 | 74 | const steamUser = await loginSteamClient(config.authCodeLogin); 75 | steamUser.on('error', (err) => { 76 | L.error(err); 77 | }); 78 | const waitForGame = promiseTimeout( 79 | new Promise((resolve) => { 80 | steamUser.once('appLaunched', (id) => { 81 | if (id === 730) { 82 | resolve(); 83 | } 84 | }); 85 | }), 86 | { milliseconds: 30000, message: 'Timed out waiting for game to launch' }, 87 | ); 88 | steamUser.gamesPlayed(730, true); 89 | await waitForGame; 90 | const csgo = new GlobalOffensive(steamUser); 91 | 92 | // robust match response promise handler 93 | const pendingMatchResponses = new Map(); 94 | csgo.on('matchList', (matches) => { 95 | L.trace({ matchesLength: matches.length }, 'Recieved matchList event'); 96 | matches.forEach((match) => { 97 | const cb = pendingMatchResponses.get(match.matchid); 98 | if (cb) { 99 | L.debug({ matchId: match.matchid }, 'Resolving match request'); 100 | pendingMatchResponses.delete(match.matchid); 101 | cb(match); 102 | } 103 | }); 104 | }); 105 | 106 | L.info({ shareCodes }, 'Requesting metadata from game coordinator'); 107 | const requestGameQueue = new PQueue({ concurrency: 1 }); 108 | const matchFetchResults = await Promise.all( 109 | shareCodes.map((shareCode) => 110 | requestGameQueue.add( 111 | async () => { 112 | try { 113 | const { matchId } = decodeMatchShareCode(shareCode); 114 | L.debug({ matchId, shareCode }, 'Requesting game data'); 115 | const [match] = await Promise.all([ 116 | promiseTimeout( 117 | new Promise((resolve) => { 118 | pendingMatchResponses.set(matchId.toString(), resolve); 119 | }), 120 | { 121 | milliseconds: 30000, 122 | message: `Error fetching match data for match ${shareCode}`, 123 | }, 124 | ), 125 | csgo.requestGame(shareCode), 126 | ]); 127 | return match; 128 | } catch (err) { 129 | L.error({ err, shareCode }); 130 | return undefined; 131 | } 132 | }, 133 | { throwOnTimeout: true }, 134 | ), 135 | ), 136 | ); 137 | const resolvedMatches = matchFetchResults.filter( 138 | (match): match is GlobalOffensive.Match => match !== undefined, 139 | ); 140 | 141 | // Quit CS 142 | const waitForQuit = promiseTimeout( 143 | new Promise((resolve) => { 144 | steamUser.once('appQuit', (id) => { 145 | if (id === 730) { 146 | resolve(); 147 | } 148 | }); 149 | }), 150 | { milliseconds: 30000, message: 'Timed out waiting for game to quit' }, 151 | ); 152 | steamUser.gamesPlayed([], true); 153 | await waitForQuit; 154 | steamUser.logOff(); 155 | 156 | L.info({ resolvedMatchesCount: resolvedMatches.length }, 'Downloading new matches'); 157 | 158 | // Convert demo download metadata 159 | const dlMatches: DownloadableMatch[] = resolvedMatches.map((match) => { 160 | const playerCount = match.roundstatsall[0]?.reservation.account_ids.filter((id) => id !== 0) 161 | .length; 162 | const isWingman = playerCount && playerCount <= 4; 163 | const isPremier = match.roundstatsall[0]?.b_switched_teams; // null for comp, true for premier 164 | let type: string; 165 | if (isWingman) { 166 | type = 'wingman'; 167 | } else if (isPremier) { 168 | type = 'premier'; 169 | } else { 170 | type = 'competitive'; 171 | } 172 | return { 173 | matchId: BigInt(match.matchid), 174 | url: match.roundstatsall.at(-1)?.map as string | undefined, 175 | date: new Date((match.matchtime as number) * 1000), 176 | type, 177 | }; 178 | }); 179 | 180 | // Download the demos 181 | await appendDemoLog(dlMatches); 182 | const downloadResults = await Promise.all( 183 | dlMatches.map((match) => 184 | downloadQueue.add(() => downloadSaveDemo(match), { throwOnTimeout: true }), 185 | ), 186 | ); 187 | const failedDownloads = downloadResults.filter((id): id is bigint => id !== null).sort(); 188 | 189 | // Use each user's MatchIdentifiers to set the last working shareCode in the store 190 | await Promise.all( 191 | usersShareCodeIds.map(async (userShareCodeIds): Promise => { 192 | let lastWorkingIdentifier: MatchIdentifier | undefined; 193 | userShareCodeIds.some((matchIdentifier) => { 194 | if ( 195 | resolvedMatches.some((match) => match.matchid === matchIdentifier.matchId.toString()) && 196 | !failedDownloads.includes(matchIdentifier.matchId) 197 | ) { 198 | lastWorkingIdentifier = matchIdentifier; 199 | return false; 200 | } 201 | return true; 202 | }, undefined); 203 | if (lastWorkingIdentifier) 204 | await setStoreValue( 205 | 'lastShareCode', 206 | lastWorkingIdentifier.steamId, 207 | lastWorkingIdentifier.shareCode, 208 | ); 209 | }), 210 | ); 211 | }; 212 | -------------------------------------------------------------------------------- /src/steam.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { LoginSession, EAuthTokenPlatformType } from 'steam-session'; 3 | import SteamTotp from 'steam-totp'; 4 | import SteamUser from 'steam-user'; 5 | import promiseTimeout from 'p-timeout'; 6 | import { getStoreValue, setStoreValue } from './store.js'; 7 | import type { LoginCredential } from './config.js'; 8 | import logger from './logger.js'; 9 | 10 | export const loginSteamWeb = async (user: LoginCredential): Promise => { 11 | const L = logger.child({ username: user.username }); 12 | L.debug('Logging user into steam web session'); 13 | const session = new LoginSession(EAuthTokenPlatformType.WebBrowser); 14 | const refreshToken = await getStoreValue('refreshToken', user.username); 15 | 16 | if (refreshToken) { 17 | L.trace('Logging into Steam with refresh token'); 18 | try { 19 | session.refreshToken = refreshToken; 20 | // session.renewRefreshToken() // This probably won't work, so we'll just let the refresh token expire 21 | } catch (err) { 22 | L.error({ err }, 'Error setting refresh token'); 23 | } 24 | } else { 25 | let authCode: string | undefined; 26 | if (user.secret) { 27 | L.trace('Getting Steam Guard auth code'); 28 | authCode = SteamTotp.getAuthCode(user.secret); 29 | } 30 | 31 | const waitForAuthentication = promiseTimeout( 32 | new Promise((resolve) => { 33 | session.once('authenticated', () => { 34 | resolve(); 35 | }); 36 | }), 37 | { milliseconds: 30000, message: 'Timed out waiting for Steam authenticated' }, 38 | ); 39 | 40 | L.debug('Logging into Steam with password'); 41 | await session.startWithCredentials({ 42 | accountName: user.username, 43 | password: user.password, 44 | steamGuardCode: authCode, 45 | }); 46 | await waitForAuthentication; 47 | } 48 | const cookies = await session.getWebCookies(); 49 | L.debug('Steam login successful'); 50 | await setStoreValue('refreshToken', user.username, session.refreshToken); 51 | return cookies; 52 | }; 53 | 54 | export const loginSteamClient = async (user: LoginCredential): Promise => { 55 | const L = logger.child({ username: user.username }); 56 | L.debug('Logging user into steam client'); 57 | const steamUser = new SteamUser(); 58 | const refreshToken = await getStoreValue('refreshToken', user.username); 59 | 60 | const waitForAuthentication = promiseTimeout( 61 | new Promise((resolve) => { 62 | steamUser.once('loggedOn', () => { 63 | resolve(); 64 | }); 65 | }), 66 | { milliseconds: 30000, message: 'Timed out waiting for Steam logged on' }, 67 | ); 68 | 69 | if (refreshToken) { 70 | L.trace('Logging into Steam with refresh token'); 71 | 72 | steamUser.logOn({ refreshToken }); 73 | // session.renewRefreshToken() // This probably won't work, so we'll just let the refresh token expire 74 | await waitForAuthentication; 75 | } else { 76 | L.trace('Getting Steam Guard auth code'); 77 | let authCode: string | undefined; 78 | if (user.secret) { 79 | L.trace('Getting Steam Guard auth code'); 80 | authCode = SteamTotp.getAuthCode(user.secret); 81 | } 82 | 83 | const waitForRefreshToken = promiseTimeout( 84 | new Promise((resolve) => { 85 | steamUser.once('refreshToken' as never, (_refreshToken: string) => { 86 | resolve(_refreshToken); 87 | }); 88 | }), 89 | { milliseconds: 30000, message: 'Timed out waiting for Steam refresh token' }, 90 | ); 91 | 92 | L.debug('Logging into Steam with password'); 93 | steamUser.logOn({ 94 | accountName: user.username, 95 | password: user.password, 96 | twoFactorCode: authCode, 97 | }); 98 | await waitForAuthentication; 99 | const newRefreshToken = await waitForRefreshToken; 100 | await setStoreValue('refreshToken', user.username, newRefreshToken); 101 | } 102 | L.debug('Steam login successful'); 103 | return steamUser; 104 | }; 105 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { outputJSON, readJSON } from 'fs-extra/esm'; 2 | import path from 'node:path'; 3 | import L from './logger.js'; 4 | 5 | export interface Store { 6 | lastCodeDemoId: Record; 7 | lastContinueToken: Record; 8 | refreshToken: Record; 9 | lastShareCode: Record; 10 | } 11 | 12 | const configDir = process.env['CONFIG_DIR'] || 'config'; 13 | const storeFile = path.join(configDir, 'store.json'); 14 | 15 | export const readStore = async (): Promise => { 16 | try { 17 | const store = (await readJSON(storeFile, 'utf-8')) as Store | undefined; 18 | if (typeof store === 'object') { 19 | return store; 20 | } 21 | } catch (err) { 22 | L.warn({ err }, 'Error reading store JSON'); 23 | } 24 | return { lastCodeDemoId: {}, lastContinueToken: {}, refreshToken: {}, lastShareCode: {} }; 25 | }; 26 | 27 | export const getStoreValue = async ( 28 | type: keyof Store, 29 | accountName: string, 30 | ): Promise => { 31 | const store = await readStore(); 32 | return store[type]?.[accountName]; 33 | }; 34 | 35 | export const setStore = (store: Store): Promise => { 36 | return outputJSON(storeFile, store, { encoding: 'utf-8' }); 37 | }; 38 | 39 | export const setStoreValue = async ( 40 | type: keyof Store, 41 | accountName: string, 42 | value: string, 43 | ): Promise => { 44 | L.trace({ type, accountName, value }, 'Setting store value'); 45 | const store = await readStore(); 46 | if (!store[type]) { 47 | store[type] = {}; 48 | } 49 | store[type][accountName] = value; 50 | return setStore(store); 51 | }; 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tsconfig/strictest/tsconfig", 4 | "@tsconfig/node18/tsconfig", 5 | ], 6 | "compilerOptions": { 7 | // ESM 8 | "module": "node16", 9 | "moduleResolution": "node16", 10 | // Disable extra strict rules 11 | "exactOptionalPropertyTypes": false, 12 | // Handle DOM types 13 | "lib": [ 14 | "es2023", 15 | "DOM", 16 | ], 17 | "typeRoots": [ 18 | "node_modules/@types", 19 | ], 20 | // Output 21 | "outDir": "dist", 22 | "inlineSourceMap": true, // Sourcemaps for debugging 23 | "rootDir": ".", // Make dist layout the same between local and docker 24 | }, 25 | "include": [ 26 | "**/*.ts", 27 | ".*.cjs", 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "dist" 32 | ] 33 | } --------------------------------------------------------------------------------