├── .dockerignore ├── .babelrc ├── api ├── .babelrc ├── overwatch.jpg ├── src │ ├── parser │ │ ├── index.js │ │ ├── svg.js │ │ ├── profile.js │ │ ├── stats.js │ │ └── utils.js │ ├── owl │ │ ├── index.js │ │ ├── schedule.js │ │ ├── standings.js │ │ └── live.js │ └── index.js ├── test │ ├── owl │ │ ├── standings.js │ │ ├── live.js │ │ └── schedule.js │ └── parser │ │ ├── svg.js │ │ ├── utils.js │ │ ├── profile.js │ │ └── stats.js ├── package.json ├── README.md └── index.d.ts ├── overwatch.jpg ├── Procfile ├── .gitignore ├── server ├── config.js ├── utils.js ├── index.js ├── routes │ ├── index.js │ ├── owl │ │ ├── live.js │ │ ├── schedule.js │ │ └── standings.js │ ├── stats.js │ └── profile.js └── cache.js ├── Dockerfile ├── app.json ├── docker-compose.yml ├── .eslintrc ├── .github └── workflows │ ├── node.js.yml │ ├── npmpublish.yml │ └── docker-publish.yml ├── LICENSE ├── package.json ├── test └── utils.js └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "@babel/preset-env" ] 3 | } -------------------------------------------------------------------------------- /api/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /overwatch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfg/overwatch-api/HEAD/overwatch.jpg -------------------------------------------------------------------------------- /api/overwatch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfg/overwatch-api/HEAD/api/overwatch.jpg -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node node_modules/srv-cli/build/srv server/index.js --docs server/routes 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | logs/ 4 | docs/ 5 | .DS_Store 6 | .nyc_output/ 7 | .vscode/ -------------------------------------------------------------------------------- /api/src/parser/index.js: -------------------------------------------------------------------------------- 1 | import profile from './profile'; 2 | import stats from './stats'; 3 | 4 | export { 5 | profile as getProfile, 6 | stats as getStats, 7 | } -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | CACHE_TTL: process.env.CACHE_TTL || 60 * 5, // 5 minute default. 3 | REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379', 4 | }; 5 | 6 | export default config; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | WORKDIR /opt/overwatch-api 4 | COPY package.json /opt/overwatch-api/package.json 5 | RUN npm install && npm install -g pm2 6 | COPY . /opt/overwatch-api 7 | EXPOSE 3000 8 | 9 | CMD ["pm2-runtime", "npm run docs"] -------------------------------------------------------------------------------- /api/src/owl/index.js: -------------------------------------------------------------------------------- 1 | import live from './live'; 2 | import schedule from './schedule'; 3 | import standings from './standings'; 4 | 5 | 6 | export { 7 | live as getLiveMatch, 8 | schedule as getSchedule, 9 | standings as getStandings, 10 | } -------------------------------------------------------------------------------- /api/src/index.js: -------------------------------------------------------------------------------- 1 | import { getProfile, getStats } from './parser'; 2 | import { getLiveMatch, getSchedule, getStandings } from './owl'; 3 | 4 | const owl = { getLiveMatch, getSchedule, getStandings }; 5 | 6 | export { 7 | getProfile, 8 | getStats, 9 | owl, 10 | } -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overwatch-api", 3 | "description": "An Unofficial Overwatch API", 4 | "repository": "https://github.com/alfg/overwatch-api", 5 | "keywords": [ 6 | "node", 7 | "overwatch", 8 | "api", 9 | "rest", 10 | "http", 11 | "srv", 12 | "express" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | overwatch-api: 5 | # image: alfg/overwatch-api:latest 6 | build: ./ 7 | ports: 8 | - "3000:3000" 9 | environment: 10 | - CACHE_TTL=30 11 | - REDIS_URL=redis://redis:6379 12 | links: 13 | - redis 14 | 15 | redis: 16 | image: redis 17 | ports: 18 | - "6379:6379" -------------------------------------------------------------------------------- /api/test/owl/standings.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getStandings } from '../../src/owl'; 3 | 4 | var result; 5 | 6 | test.before.cb(t => { 7 | getStandings((err, json) => { 8 | if (err) t.fail(); 9 | 10 | result = json; 11 | t.end(); 12 | }); 13 | }); 14 | 15 | test.skip('get base standings data', t => { 16 | t.deepEqual(Array.isArray(result.data), true); 17 | t.deepEqual(result.data.length > 0, true); 18 | }); -------------------------------------------------------------------------------- /api/test/owl/live.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getLiveMatch } from '../../src/owl'; 3 | 4 | var result; 5 | 6 | test.before.cb(t => { 7 | getLiveMatch((err, json) => { 8 | if (err) t.fail(); 9 | 10 | result = json; 11 | t.end(); 12 | }); 13 | }); 14 | 15 | test.skip('get base liveMatch data', t => { 16 | t.deepEqual(typeof(result.data), 'object'); 17 | t.deepEqual(typeof(result.data.liveMatch), 'object'); 18 | }); -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "globals": { 4 | "require": true, 5 | "module": true, 6 | "Buffer": true, 7 | "exports": true, 8 | "process": true 9 | }, 10 | "parserOptions": { 11 | "ecmaVersion": 6, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": false }] 16 | } 17 | } -------------------------------------------------------------------------------- /api/src/owl/schedule.js: -------------------------------------------------------------------------------- 1 | import rp from 'request-promise'; 2 | 3 | 4 | export default function(cb) { 5 | const url = 'https://api.overwatchleague.com/schedule'; 6 | 7 | const options = { 8 | uri: encodeURI(url), 9 | encoding: 'utf8', 10 | json: true, 11 | } 12 | 13 | rp(options).then((resp) => { 14 | const json = { 15 | data: resp.data, 16 | } 17 | 18 | cb(null, json); 19 | }).catch(err => { 20 | cb(err); 21 | }); 22 | } -------------------------------------------------------------------------------- /server/utils.js: -------------------------------------------------------------------------------- 1 | const utils = { 2 | filterIncludes(include, data) { 3 | // Filters response with include parameters. 4 | if (!include || include.length == 0 || typeof data == "string" || !data.hasOwnProperty(include[0])) 5 | return data; 6 | let first = include.shift(); 7 | let filtered = {}; 8 | filtered[first] = this.filterIncludes(include, data[first]); 9 | return filtered; 10 | } 11 | } 12 | 13 | export default utils; 14 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import index from './routes/index'; 2 | import profile from './routes/profile'; 3 | import stats from './routes/stats'; 4 | import live from './routes/owl/live'; 5 | import schedule from './routes/owl/schedule'; 6 | import standings from './routes/owl/standings'; 7 | 8 | 9 | export default function(app) { 10 | app.use('/', index); 11 | app.use('/profile', profile); 12 | app.use('/stats', stats); 13 | app.use('/owl/live', live); 14 | app.use('/owl/standings', standings); 15 | app.use('/owl/schedule', schedule); 16 | } 17 | -------------------------------------------------------------------------------- /api/test/owl/schedule.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getSchedule } from '../../src/owl'; 3 | 4 | var result; 5 | 6 | test.before.cb(t => { 7 | getSchedule((err, json) => { 8 | if (err) t.fail(); 9 | 10 | result = json; 11 | t.end(); 12 | }); 13 | }); 14 | 15 | test.skip('get base schedule data', t => { 16 | t.deepEqual(typeof(result.data), 'object'); 17 | t.deepEqual(typeof(result.data.startDate), 'string'); 18 | t.deepEqual(typeof(result.data.endDate), 'string'); 19 | t.deepEqual(Array.isArray(result.data.stages), true); 20 | t.deepEqual(result.data.stages.length > 0, true); 21 | }); -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [12.x, 14.x, 16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - run: npm run build --if-present 27 | - run: npm test -------------------------------------------------------------------------------- /api/test/parser/svg.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { createEndorsementSVG } from '../../src/parser/svg'; 3 | 4 | test('create a valid endorsement SVG', t => { 5 | const endorsementData = { 6 | sportsmanship: { value: 18, rate: 31.58 }, 7 | shotcaller: { value: 4, rate: 7.02 }, 8 | teammate: { value: 35, rate: 61.4 }, 9 | points: 57, 10 | level: 2, 11 | }; 12 | 13 | const svg = createEndorsementSVG(endorsementData); 14 | const decoded = Buffer.from(svg.slice('data:image/svg+xml;base64,'.length), 'base64').toString('utf8'); 15 | 16 | t.true(svg.startsWith('data:image/svg+xml;base64,')); 17 | t.regex(svg, /[A-Za-z0-9+/=]/); 18 | t.true(decoded.startsWith('')); 20 | }); -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | import { name, description, version, bugs, homepage } from '../../package.json'; 4 | 5 | 6 | /** 7 | * @api {get} / Get API status. 8 | * @apiName GetAPI 9 | * @apiGroup API 10 | * 11 | * @apiExample {curl} Example usage: 12 | * curl -i https://owapi.io/ 13 | * 14 | * @apiSuccessExample {json} Success-Response: 15 | HTTP/1.1 200 OK 16 | { 17 | name: "overwatch-api", 18 | description: "Overwatch API", 19 | version: "0.0.1", 20 | homepage: "https://github.com/alfg/overwatch-api", 21 | bugs: "https://github.com/alfg/overwatch-api/issues", 22 | docs: "https://owapi.io/docs" 23 | } 24 | */ 25 | router.get('/', (req, res) => { 26 | 27 | const json = { 28 | name, 29 | description, 30 | version, 31 | homepage, 32 | bugs: bugs.url, 33 | docs: `${req.protocol}://${req.get('host')}${req.originalUrl}docs` 34 | }; 35 | res.json(json); 36 | }); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /api/src/owl/standings.js: -------------------------------------------------------------------------------- 1 | import rp from 'request-promise'; 2 | 3 | 4 | export default function(cb) { 5 | const url = 'https://api.overwatchleague.com/v2/standings'; 6 | 7 | const options = { 8 | uri: encodeURI(url), 9 | encoding: 'utf8', 10 | json: true, 11 | } 12 | 13 | rp(options).then((resp) => { 14 | 15 | const json = { 16 | data: transform(resp.data), 17 | } 18 | 19 | cb(null, json); 20 | }).catch(err => { 21 | cb(err); 22 | }); 23 | } 24 | 25 | function transform(data) { 26 | const includes = [ 27 | 'id', 28 | 'divisionId', 29 | 'name', 30 | 'abbreviatedName', 31 | 'league', 32 | 'stages', 33 | 'preseason', 34 | ]; 35 | 36 | // Filter only the properties we want to use. 37 | const filtered = data.map(o => { 38 | return Object.keys(o) 39 | .filter(key => includes.includes(key)) 40 | .reduce((obj, key) => { 41 | obj[key] = o[key]; 42 | return obj; 43 | }, {}); 44 | }); 45 | return filtered; 46 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2020 Alfred Gutierrez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm test 17 | 18 | publish-npm: 19 | needs: build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v1 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: 12 26 | registry-url: https://registry.npmjs.org/ 27 | - run: npm ci 28 | - run: npm publish 29 | env: 30 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 31 | 32 | publish-npm-client: 33 | needs: build 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v1 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: 12 40 | registry-url: https://registry.npmjs.org/ 41 | - run: | 42 | cd api 43 | npm ci 44 | npm publish 45 | env: 46 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 47 | -------------------------------------------------------------------------------- /server/routes/owl/live.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | import { getLiveMatch } from '../../../api/src/owl'; 5 | import cache from '../../cache'; 6 | 7 | 8 | /** 9 | * @api {get} /owl/live Get live match stats. 10 | * @apiName GetLive 11 | * @apiGroup OWL 12 | * 13 | * @apiSuccess {Object} data Live Match data. 14 | * 15 | * @apiExample {curl} Example usage: 16 | * curl -i https://owapi.io/owl/live 17 | * 18 | * @apiSuccessExample {json} Success-Response: 19 | HTTP/1.1 200 OK 20 | { 21 | data: {} 22 | } 23 | */ 24 | router.get('/', (req, res) => { 25 | const cacheKey = `owl_live_`; 26 | const timeout = 30; // 30 seconds. 27 | 28 | cache.getOrSet(cacheKey, timeout, fnLive, function(err, data) { 29 | if (err) return res.json({ message: err.toString() }); 30 | 31 | if (data.statusCode) { 32 | res.status(data.response.statusCode).send(data.response.statusMessage); 33 | } else { 34 | res.json(data); 35 | } 36 | }); 37 | 38 | function fnLive(callback) { 39 | getLiveMatch((err, data) => { 40 | if (err) return callback(err); 41 | callback(data); 42 | }); 43 | } 44 | }); 45 | 46 | export default router; -------------------------------------------------------------------------------- /server/routes/owl/schedule.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | import { getSchedule } from '../../../api/src/owl'; 5 | import cache from '../../cache'; 6 | 7 | 8 | /** 9 | * @api {get} /owl/schedule Get schedule. 10 | * @apiName GetSchedule 11 | * @apiGroup OWL 12 | * 13 | * @apiSuccess {Object} data OWL schedule data. 14 | * 15 | * @apiExample {curl} Example usage: 16 | * curl -i https://owapi.io/owl/schedule 17 | * 18 | * @apiSuccessExample {json} Success-Response: 19 | HTTP/1.1 200 OK 20 | { 21 | data: {} 22 | } 23 | */ 24 | router.get('/', (req, res) => { 25 | const cacheKey = `owl_schedule_`; 26 | const timeout = 30; // 30 seconds. 27 | 28 | cache.getOrSet(cacheKey, timeout, fnSchedule, function(err, data) { 29 | if (err) return res.json({ message: err.toString() }); 30 | 31 | if (data.statusCode) { 32 | res.status(data.response.statusCode).send(data.response.statusMessage); 33 | } else { 34 | res.json(data); 35 | } 36 | }); 37 | 38 | function fnSchedule(callback) { 39 | getSchedule((err, data) => { 40 | if (err) return callback(err); 41 | callback(data); 42 | }); 43 | } 44 | }); 45 | 46 | export default router; -------------------------------------------------------------------------------- /server/routes/owl/standings.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | import { getStandings } from '../../../api/src/owl'; 5 | import cache from '../../cache'; 6 | 7 | 8 | /** 9 | * @api {get} /owl/standings Get standings. 10 | * @apiName GetStandings 11 | * @apiGroup OWL 12 | * 13 | * @apiSuccess {Object} data OWL standings data. 14 | * 15 | * @apiExample {curl} Example usage: 16 | * curl -i https://owapi.io/owl/standings 17 | * 18 | * @apiSuccessExample {json} Success-Response: 19 | HTTP/1.1 200 OK 20 | { 21 | data: {} 22 | } 23 | */ 24 | router.get('/', (req, res) => { 25 | const cacheKey = `owl_standings_`; 26 | const timeout = 30; // 30 seconds. 27 | 28 | cache.getOrSet(cacheKey, timeout, fnStandings, function(err, data) { 29 | if (err) return res.json({ message: err.toString() }); 30 | 31 | if (data.statusCode) { 32 | res.status(data.response.statusCode).send(data.response.statusMessage); 33 | } else { 34 | res.json(data); 35 | } 36 | }); 37 | 38 | function fnStandings(callback) { 39 | getStandings((err, data) => { 40 | if (err) return callback(err); 41 | callback(data); 42 | }); 43 | } 44 | }); 45 | 46 | export default router; -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Log in to the Container registry 24 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 25 | with: 26 | registry: ${{ env.REGISTRY }} 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Extract metadata (tags, labels) for Docker 31 | id: meta 32 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 33 | with: 34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 35 | 36 | - name: Build and push Docker image 37 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overwatch-api", 3 | "version": "0.13.0", 4 | "description": "An Unoffical Overwatch API.", 5 | "main": "lib/index.js", 6 | "typings": "index.d.ts", 7 | "engines": { 8 | "node": ">=8.17.0" 9 | }, 10 | "dependencies": { 11 | "async": "^3.1.0", 12 | "cheerio": "^0.22.0", 13 | "request": "^2.74.0", 14 | "request-promise": "^4.1.1", 15 | "svg-builder": "^1.0.0" 16 | }, 17 | "devDependencies": { 18 | "@ava/babel": "^1.0.1", 19 | "@babel/cli": "^7.10.4", 20 | "@babel/core": "^7.10.4", 21 | "@babel/polyfill": "^7.10.4", 22 | "@babel/register": "^7.10.4", 23 | "ava": "^3.10.1", 24 | "nyc": "^15.1.0" 25 | }, 26 | "scripts": { 27 | "compile": "babel -d lib/ src/", 28 | "test": "./node_modules/.bin/nyc ava -v", 29 | "prepublishOnly": "npm run compile" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/alfg/overwatch-api/api.git" 34 | }, 35 | "keywords": [ 36 | "node", 37 | "overwatch", 38 | "api" 39 | ], 40 | "author": "Alf", 41 | "license": "MIT", 42 | "ava": { 43 | "files": [ 44 | "test/**/*" 45 | ], 46 | "babel": true, 47 | "require": [ 48 | "@babel/register", 49 | "@babel/polyfill" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/cache.js: -------------------------------------------------------------------------------- 1 | import redis from 'redis'; 2 | import config from './config'; 3 | 4 | // Make redis connection. 5 | const client = redis.createClient({ 6 | url: config.REDIS_URL 7 | }); 8 | 9 | client.on('error', (err) => { 10 | console.log(err); 11 | }); 12 | 13 | var cache = { 14 | /** Gets key from cache if exists, else sets the cache and returns data. 15 | * @param {string} cacheKey - Key to get or set. 16 | * @param {integer} timeout - Timeout (in seconds) for cache to release. 17 | * @param {Function} fn - Function to get data if key does not exist. 18 | * @param {Function} callback - Callback function to send back data or value. 19 | */ 20 | getOrSet: function(cacheKey, timeout, fn, callback) { 21 | 22 | // Get cacheKey. If cacheKey is not present (or expired), then set the key with a timeout. 23 | client.get(cacheKey, (err, reply) => { 24 | if (err) return callback(err); 25 | 26 | // If we got reply data, send it back. 27 | if (reply) return callback(null, JSON.parse(reply)); 28 | 29 | // Run function to get data to cache with an expiration. 30 | fn((err, data) => { 31 | if (err) return callback(err); 32 | client.set(cacheKey, JSON.stringify(data), 'EX', timeout, (err, reply) => { 33 | if (err) return callback(err); 34 | }); 35 | return callback(null, data); 36 | }); 37 | }); 38 | } 39 | } 40 | 41 | module.exports = cache; 42 | -------------------------------------------------------------------------------- /api/test/parser/utils.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getPrestigeLevel, getPrestigeStars } from '../../src/parser/utils'; 3 | 4 | test('get the prestige level 0', t => { 5 | const code = "0x0250000000000918"; 6 | const level = getPrestigeLevel(code); 7 | 8 | t.deepEqual(level, 0); 9 | }); 10 | 11 | test('get the prestige level 18', t => { 12 | const code = "69fde7abebb0bb5aa870e62362e84984cae13e441aec931a5e2c9dc5d22a56dc"; 13 | const level = getPrestigeLevel(code); 14 | 15 | t.deepEqual(level, 18); 16 | }); 17 | 18 | test('get the prestige level default if not exists', t => { 19 | const code = "0xdeadc0de"; 20 | const level = getPrestigeLevel(code); 21 | 22 | t.deepEqual(level, 0); 23 | }); 24 | 25 | test('get the prestige level default if not exists as integer', t => { 26 | const code = 12345; 27 | const level = getPrestigeLevel(code); 28 | 29 | t.deepEqual(level, 0); 30 | }); 31 | 32 | test('get the prestige level default if given a bad value', t => { 33 | const code = "!@#!!"; 34 | const level = getPrestigeLevel(code); 35 | 36 | t.deepEqual(level, 0); 37 | }); 38 | 39 | test('returns 0 stars by default', t => { 40 | const stars = getPrestigeStars(''); 41 | t.is(stars, 0); 42 | }); 43 | 44 | test('returns stars for uuid', t => { 45 | const stars = getPrestigeStars('cff520765f143c521b25ad19e560abde9a90eeae79890b14146a60753d7baff8'); 46 | t.is(stars, 4); 47 | }); 48 | -------------------------------------------------------------------------------- /api/src/owl/live.js: -------------------------------------------------------------------------------- 1 | import rp from 'request-promise'; 2 | 3 | 4 | export default function(cb) { 5 | const url = 'https://api.overwatchleague.com/live-match'; 6 | 7 | const options = { 8 | uri: encodeURI(url), 9 | encoding: 'utf8', 10 | json: true, 11 | } 12 | 13 | rp(options).then((resp) => { 14 | const json = { 15 | data: transform(resp.data), 16 | } 17 | 18 | cb(null, json); 19 | }).catch(err => { 20 | cb(err); 21 | }); 22 | } 23 | 24 | function transform(data) { 25 | let t; 26 | 27 | if (Object.getOwnPropertyNames(data.liveMatch).length === 0) { 28 | t = { 29 | liveMatch: {}, 30 | } 31 | return t; 32 | } 33 | 34 | t = { 35 | liveMatch: { 36 | competitors: data.liveMatch.competitors.map(o => 37 | ({ 38 | name: o.name, 39 | primaryColor: o.primaryColor, 40 | secondaryColor: o.secondaryColor, 41 | abbreviatedName: o.abbreviatedName, 42 | logo: o.logo, 43 | }) 44 | ), 45 | scores: data.liveMatch.scores, 46 | status: data.liveMatch.status, 47 | games: data.liveMatch.games.map(o => 48 | ({ 49 | number: o.number, 50 | points: o.points, 51 | state: o.state, 52 | map: o.attributes.map 53 | }) 54 | ), 55 | startDate: data.liveMatch.startDate, 56 | endDate: data.liveMatch.endDate, 57 | wins: data.liveMatch.wins, 58 | ties: data.liveMatch.ties, 59 | losses: data.liveMatch.losses, 60 | timeToMatch: data.liveMatch.timeToMatch, 61 | liveStatus: data.liveMatch.liveStatus, 62 | } 63 | } 64 | return t; 65 | } -------------------------------------------------------------------------------- /api/test/parser/profile.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getProfile } from '../../src/parser'; 3 | 4 | const platform = 'pc' 5 | const region = 'us' 6 | const tag = 'Jay3-11894' 7 | 8 | var result; 9 | 10 | test.before.cb(t => { 11 | getProfile(platform, region, tag, (err, json) => { 12 | if (err) t.fail(); 13 | 14 | result = json; 15 | t.end(); 16 | }) 17 | }); 18 | 19 | test('get base information of user profile', t => { 20 | t.deepEqual(typeof(result.username), 'string'); 21 | t.deepEqual(result.portrait.startsWith('http'), true); 22 | }); 23 | 24 | test('get information of games played by user', t => { 25 | t.deepEqual(typeof(result.games.quickplay.won), 'number'); 26 | t.deepEqual(typeof(result.games.competitive.won), 'number'); 27 | t.deepEqual(typeof(result.games.competitive.lost), 'number'); 28 | t.deepEqual(typeof(result.games.competitive.draw), 'number'); 29 | t.deepEqual(typeof(result.games.competitive.played), 'number'); 30 | t.deepEqual(typeof(result.games.competitive.win_rate), 'number'); 31 | }); 32 | 33 | test('get information of user playtime', t => { 34 | t.not(typeof(result.playtime.quickplay), 'undefined'); 35 | t.not(typeof(result.playtime.competitive), 'undefined'); 36 | }); 37 | 38 | test('get information of user competitive stats', t => { 39 | t.deepEqual(typeof(result.competitive.tank.rank), 'string'); 40 | t.deepEqual(result.competitive.tank.icon.startsWith('http'), true); 41 | t.deepEqual(typeof(result.competitive.offense.rank), 'string'); 42 | t.deepEqual(result.competitive.offense.icon.startsWith('http'), true); 43 | t.deepEqual(typeof(result.competitive.support.rank), 'string'); 44 | t.deepEqual(result.competitive.support.icon.startsWith('http'), true); 45 | }); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "overwatch-api-server", 3 | "version": "0.12.0", 4 | "description": "An Unoffical Overwatch HTTP API", 5 | "main": "server/index.js", 6 | "engines": { 7 | "node": ">=8.17.0" 8 | }, 9 | "dependencies": { 10 | "async": "^2.6.1", 11 | "cheerio": "^0.22.0", 12 | "redis": "^3.0.2", 13 | "request": "^2.74.0", 14 | "request-promise": "^4.1.1", 15 | "srv-cli": "0.4.1", 16 | "svg-builder": "^1.0.0" 17 | }, 18 | "devDependencies": { 19 | "@ava/babel": "^1.0.1", 20 | "@babel/polyfill": "^7.10.4", 21 | "@babel/register": "^7.10.4", 22 | "ava": "^3.10.1", 23 | "nyc": "^15.1.0" 24 | }, 25 | "scripts": { 26 | "start": "node node_modules/srv-cli/build/srv server/index.js", 27 | "start-dev": "nodemon node_modules/srv-cli/build/srv server/index.js", 28 | "docs": "node node_modules/srv-cli/build/srv server/index.js --docs server/routes", 29 | "debug": "node --nolazy --debug-brk=5858 node_modules/srv-cli/build/srv server/index.js", 30 | "lint": "node node_modules/srv-cli/build/srv --lint", 31 | "test-api": "cd api && nyc ava -v", 32 | "test-server": "nyc ava -v", 33 | "test": "npm run test-server && npm run test-api" 34 | }, 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/alfg/overwatch-api.git" 38 | }, 39 | "keywords": [ 40 | "node", 41 | "overwatch", 42 | "api", 43 | "rest", 44 | "http", 45 | "srv", 46 | "express" 47 | ], 48 | "author": "Alf", 49 | "license": "MIT", 50 | "bugs": { 51 | "url": "https://github.com/alfg/overwatch-api/issues" 52 | }, 53 | "homepage": "https://github.com/alfg/overwatch-api", 54 | "ava": { 55 | "files": [ 56 | "test/**/*" 57 | ], 58 | "babel": true, 59 | "require": [ 60 | "@babel/register", 61 | "@babel/polyfill" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import utils from '../server/utils'; 3 | 4 | const data = { 5 | "username": "Calvin", 6 | "level": 733, 7 | "portrait": "https://d1u1mce87gyfbn.cloudfront.net/game/unlocks/0x0250000000000EF7.png", 8 | "games": { 9 | "quickplay": { 10 | "won": 647 11 | }, 12 | "competitive": { 13 | "won": 286, 14 | "lost": 186, 15 | "draw": 7, 16 | "played": 480 17 | } 18 | }, 19 | "playtime": { 20 | "quickplay": "129 hours", 21 | "competitive": "99 hours" 22 | }, 23 | "competitive": { 24 | "rank": 4582, 25 | "rank_img": "https://d1u1mce87gyfbn.cloudfront.net/game/rank-icons/season-2/rank-7.png" 26 | }, 27 | "levelFrame": "https://d1u1mce87gyfbn.cloudfront.net/game/playerlevelrewards/0x0250000000000969_Border.", 28 | "star": "https://d1u1mce87gyfbn.cloudfront.net/game/playerlevelrewards/0x0250000000000969_Rank." 29 | }; 30 | 31 | test('filter by username', t => { 32 | const filter = ["username"]; 33 | const stats = utils.filterIncludes(filter, data); 34 | 35 | t.deepEqual(stats, { username: "Calvin" }); 36 | }); 37 | 38 | test('filter by level', t => { 39 | const filter = ["level"]; 40 | const stats = utils.filterIncludes(filter, data); 41 | 42 | t.deepEqual(stats, { level: 733 }); 43 | }); 44 | 45 | test('filter by games.competitive', t => { 46 | const filter = ["games", "competitive"]; 47 | const stats = utils.filterIncludes(filter, data); 48 | 49 | t.deepEqual(stats, { 50 | games: { 51 | competitive: { 52 | won: 286, lost: 186, draw: 7, played: 480 53 | } 54 | } 55 | }); 56 | }); 57 | 58 | test('filter by invalid key', t => { 59 | const filter = ["asdf"]; 60 | const stats = utils.filterIncludes(filter, data); 61 | 62 | t.deepEqual(stats, data); 63 | }); -------------------------------------------------------------------------------- /server/routes/stats.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | import { getStats } from '../../api/src'; 5 | import cache from '../cache'; 6 | import utils from '../utils'; 7 | import config from '../config'; 8 | 9 | /** 10 | * @api {get} /stats/:platform/:region/:tag Get profile of player. 11 | * @apiName GetStats 12 | * @apiGroup Stats 13 | * 14 | * @apiParam {String} platform Platform of user. pc/xbl/psn/nintendo-switch 15 | * @apiParam {String} region Region of player. us/eu/kr/cn/global 16 | * @apiParam {String} tag BattleTag of user. Replace # with -. 17 | * @apiParam (Query String Params) {String} include Query String parameter to specifiy include filters. Comma deliminated. 18 | * @apiSuccess {Object} data Profile data. 19 | * 20 | * @apiExample {curl} Example usage: 21 | * curl -i https://owapi.io/stats/pc/us/user-12345 22 | * 23 | * @apiSuccessExample {json} Success-Response: 24 | HTTP/1.1 200 OK 25 | { 26 | username: "user" 27 | stats: { 28 | top_heroes: {...} 29 | combat: {...} 30 | } 31 | } 32 | */ 33 | router.get('/:platform/:region/:tag', (req, res) => { 34 | 35 | const platform = req.params.platform; 36 | const region = req.params.region; 37 | const tag = req.params.tag; 38 | const include = req.query.include && req.query.include.split(',') || null; 39 | 40 | const cacheKey = `stats_${platform}_${region}_${tag}`; 41 | 42 | cache.getOrSet(cacheKey, config.CACHE_TTL, fnStats, function(err, data) { 43 | if (err) return res.json({ message: err.message }); 44 | 45 | if (data.statusCode) { 46 | res.status(data.response.statusCode).send(data.response.statusMessage); 47 | } else { 48 | const filtered = utils.filterIncludes(include, data); 49 | res.json(filtered); 50 | } 51 | }); 52 | 53 | function fnStats(callback) { 54 | getStats(platform, region, tag, (err, data) => { 55 | if (err) return callback({ message: err.toString()}); 56 | return callback(err, data); 57 | }); 58 | } 59 | }); 60 | 61 | export default router; 62 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Overwatch API Node Module 2 |

3 | 4 | [![npm](https://img.shields.io/npm/v/overwatch-api.svg)](https://www.npmjs.com/package/overwatch-api) 5 | [![Build Status](https://travis-ci.org/alfg/overwatch-api.svg?branch=master)](https://travis-ci.org/alfg/overwatch-api) 6 | 7 | ## Features 8 | * Profile Data 9 | * Career Stats 10 | * Overwatch League Data 11 | * Live Match 12 | * Standings 13 | * Schedule 14 | 15 | ## Install 16 | ``` 17 | npm install --save overwatch-api 18 | ``` 19 | 20 | ## Example 21 | ```javascript 22 | const overwatch = require('overwatch-api'); 23 | 24 | const platform = 'pc'; // pc/xbl/psn/nintendo-switch 25 | const region = 'us'; 26 | const tag = 'Calvin-1337'; 27 | 28 | overwatch.getProfile(platform, region, tag, (err, json) => { 29 | if (err) console.error(err); 30 | else console.log(json); 31 | }); 32 | 33 | ``` 34 | ```javascript 35 | { username: 'Calvin', 36 | level: 861, 37 | portrait: 'https://d1u1mce87gyfbn.cloudfront.net/game/unlocks/0x0250000000000EF7.png', 38 | games: 39 | { quickplay: { won: 647, played: undefined }, 40 | competitive: { won: 15, lost: 12, draw: 0, played: 27 } }, 41 | playtime: { quickplay: '129 hours', competitive: '5 hours' }, 42 | competitive: 43 | { rank: 4416, 44 | rank_img: 'https://d1u1mce87gyfbn.cloudfront.net/game/rank-icons/season-2/rank-7.png' }, 45 | levelFrame: 'https://d1u1mce87gyfbn.cloudfront.net/game/playerlevelrewards/0x0250000000000974_Border.png', 46 | star: 'https://d1u1mce87gyfbn.cloudfront.net/game/playerlevelrewards/0x0250000000000974_Rank.png' } 47 | ``` 48 | 49 | ## API 50 | ```javascript 51 | const overwatch = require('overwatch-api'); 52 | ``` 53 | 54 | --- 55 | 56 | ### Player Data 57 | 58 | ### overwatch.getProfile(platform, region, tag, callback) 59 | `platform` - Platform of user. `pc, xbl, psn` 60 | 61 | `region` - Region of player. `us, eu, kr, cn, global` 62 | 63 | `tag` - BattleTag of user. Replace `#` with `-`. 64 | 65 | `callback(err, data)` - Callback function which returns the error and response data. 66 | 67 | ### overwatch.getStats(platform, region, tag, callback) 68 | `platform` - Platform of user. `pc, xbl, psn` 69 | 70 | `region` - Region of player. `us, eu, kr, cn, global` 71 | 72 | `tag` - BattleTag of user. Replace `#` with `-`. 73 | 74 | `callback(err, data)` - Callback function which returns the error and response data. 75 | 76 | --- 77 | 78 | ### OWL Data 79 | 80 | ### overwatch.owl.getLiveMatch(callback) 81 | `callback(err, data)` - Callback function which returns the error and response data. 82 | 83 | ### overwatch.owl.getStandings(callback) 84 | `callback(err, data)` - Callback function which returns the error and response data. 85 | 86 | ### overwatch.owl.getSchedule(callback) 87 | `callback(err, data)` - Callback function which returns the error and response data. 88 | 89 | 90 | ## License 91 | MIT 92 | -------------------------------------------------------------------------------- /api/test/parser/stats.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getStats } from '../../src/parser'; 3 | 4 | const platform = 'pc' 5 | const region = 'us' 6 | const tag = 'Jay3-11894' 7 | 8 | var result; 9 | 10 | test.before.cb(t => { 11 | getStats(platform, region, tag, (err, json) => { 12 | if (err) t.fail(); 13 | 14 | result = json; 15 | t.end(); 16 | }) 17 | }); 18 | 19 | test('get base information of user profile', t => { 20 | t.deepEqual(typeof(result.username), 'string'); 21 | t.deepEqual(result.portrait.startsWith('http'), true); 22 | }); 23 | 24 | test('get user top heroes information', t => { 25 | const topHeroCategories = { 26 | quickplay: { 27 | 'played': '0x0860000000000021', 28 | 'games_won': '0x0860000000000039', 29 | 'weapon_accuracy': '0x086000000000002F', 30 | 'eliminations_per_life': '0x08600000000003D2', 31 | 'multikill_best': '0x0860000000000346', 32 | 'objective_kills_average': '0x086000000000039C', 33 | }, 34 | competitive: { 35 | 'played': '0x0860000000000021', 36 | 'games_won': '0x0860000000000039', 37 | 'win_rate': '0x08600000000003D1', 38 | 'weapon_accuracy': '0x086000000000002F', 39 | 'eliminations_per_life': '0x08600000000003D2', 40 | 'multikill_best': '0x0860000000000346', 41 | 'objective_kills_average': '0x086000000000039C', 42 | } 43 | }; 44 | 45 | Object.keys(topHeroCategories.quickplay).forEach((k) => { 46 | result['stats']['top_heroes']['quickplay'][k].map((hero) => { 47 | t.deepEqual(typeof(hero['hero']), 'string'); 48 | t.deepEqual(typeof(hero[k]), 'string'); 49 | t.deepEqual(hero.img.startsWith('http'), true); 50 | }); 51 | }); 52 | 53 | Object.keys(topHeroCategories.competitive).forEach((k) => { 54 | result['stats']['top_heroes']['competitive'][k].map((hero) => { 55 | t.deepEqual(typeof(hero['hero']), 'string'); 56 | t.deepEqual(typeof(hero[k]), 'string'); 57 | t.deepEqual(hero.img.startsWith('http'), true); 58 | }); 59 | }); 60 | }); 61 | 62 | test('get combat stats', t => { 63 | t.is(result['stats']['combat']['quickplay'].length > 0, true); 64 | result['stats']['combat']['quickplay'].map((stat) => { 65 | t.deepEqual(typeof(stat.title), 'string'); 66 | t.deepEqual(typeof(stat.value), 'string'); 67 | }); 68 | 69 | t.is(result['stats']['combat']['competitive'].length > 0, true); 70 | result['stats']['combat']['competitive'].map((stat) => { 71 | t.deepEqual(typeof(stat.title), 'string'); 72 | t.deepEqual(typeof(stat.value), 'string'); 73 | }); 74 | }); 75 | 76 | test.skip('get death stats', t => { 77 | t.is(result['stats']['deaths']['quickplay'].length > 0, true); 78 | result['stats']['deaths']['quickplay'].map((stat) => { 79 | t.deepEqual(typeof(stat.title), 'string'); 80 | t.deepEqual(typeof(stat.value), 'string'); 81 | }); 82 | 83 | t.is(result['stats']['deaths']['competitive'].length > 0, true); 84 | result['stats']['deaths']['competitive'].map((stat) => { 85 | t.deepEqual(typeof(stat.title), 'string'); 86 | t.deepEqual(typeof(stat.value), 'string'); 87 | }); 88 | }); 89 | 90 | -------------------------------------------------------------------------------- /api/src/parser/svg.js: -------------------------------------------------------------------------------- 1 | const svgBuilder = require('svg-builder'); 2 | 3 | const Constants = { 4 | r: 15.91549430918954, 5 | width: 40, 6 | height: 40, 7 | offset: 25, 8 | strokeWidth: 3, 9 | cx: '50%', 10 | cy: '50%', 11 | }; 12 | 13 | const Colors = { 14 | gray: '#2a2b2e', 15 | orange: '#f19512', 16 | magenta: '#c81af5', 17 | green: '#40ce44', 18 | white: '#f6f6f6', 19 | transparent: 'transparent', 20 | }; 21 | 22 | // Builds data to be used by the svg-builder in svg.buildSVG. 23 | function getSVGData(endorsementsObj) { 24 | const sportsmanshipRate = endorsementsObj.sportsmanship.rate || 0; 25 | const shotcallerRate = endorsementsObj.shotcaller.rate || 0; 26 | const teammateRate = endorsementsObj.teammate.rate || 0; 27 | 28 | return { 29 | level: endorsementsObj.level, 30 | shotcaller: { 31 | dasharray: `${Math.round(shotcallerRate)} ${Math.round(100 - shotcallerRate)}`, 32 | dashoffset: 25, // Start offset at 12 o'clock. 33 | }, 34 | teammate: { 35 | dasharray: `${Math.round(teammateRate)} ${Math.round(100 - teammateRate)}`, 36 | dashoffset: 100 - Math.round(shotcallerRate) + 25, // Bump offset. 37 | }, 38 | sportsmanship: { 39 | dasharray: `${Math.round(sportsmanshipRate)} ${Math.round(100 - sportsmanshipRate)}`, 40 | dashoffset: 100 - Math.round(shotcallerRate + teammateRate) + 25, 41 | } 42 | } 43 | } 44 | 45 | // Builds the SVG endorements icon using data from svg.getSVGData. 46 | function buildSVG(data) { 47 | const svg = svgBuilder.newInstance() 48 | svg.width(40).height(40); 49 | 50 | // Shot caller circle. 51 | svg.circle({ 52 | r: Constants.r, 53 | fill: Colors.gray, 54 | 'stroke-dasharray': data.shotcaller.dasharray, 55 | 'stroke-dashoffset': data.shotcaller.dashoffset, 56 | 'stroke-width': Constants.strokeWidth, 57 | stroke: Colors.orange, 58 | cx: Constants.cx, 59 | cy: Constants.cy, 60 | }); 61 | 62 | // Teammate circle. 63 | svg.circle({ 64 | r: Constants.r, 65 | fill: Colors.transparent, 66 | 'stroke-dasharray': data.teammate.dasharray, 67 | 'stroke-dashoffset': data.teammate.dashoffset, 68 | 'stroke-width': Constants.strokeWidth, 69 | stroke: Colors.magenta, 70 | cx: Constants.cx, 71 | cy: Constants.cy, 72 | }); 73 | 74 | // Sportsmanship circle. 75 | svg.circle({ 76 | r: Constants.r, 77 | fill: Colors.transparent, 78 | 'stroke-dasharray': data.sportsmanship.dasharray, 79 | 'stroke-dashoffset': data.sportsmanship.dashoffset, 80 | 'stroke-width': Constants.strokeWidth, 81 | stroke: Colors.green, 82 | cx: Constants.cx, 83 | cy: Constants.cy, 84 | }); 85 | 86 | // Centered text with endorsement level. 87 | svg.text({ 88 | x: '50%', 89 | y: '50%', 90 | dy: '.3em', 91 | 'font-family': 'century gothic,arial,sans-serif', 92 | 'font-weight': 300, 93 | 'font-size': 16, 94 | stroke: Colors.white, 95 | 'stroke-width': '1', 96 | fill: Colors.white, 97 | 'text-anchor': 'middle', 98 | }, `${data.level}`); 99 | 100 | // Output SVG as a base64 encoded data URI. 101 | const b64 = new Buffer.from(svg.render()).toString('base64'); 102 | return `data:image/svg+xml;base64,${b64}`; 103 | } 104 | 105 | export function createEndorsementSVG(endorsementsObj) { 106 | const svgData = getSVGData(endorsementsObj); 107 | const svg = buildSVG(svgData); 108 | return svg; 109 | } -------------------------------------------------------------------------------- /server/routes/profile.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | import { getProfile } from '../../api/src'; 5 | import cache from '../cache'; 6 | import utils from '../utils'; 7 | import config from '../config'; 8 | 9 | /** 10 | * @api {get} /profile/:platform/:region/:tag Get profile of player. 11 | * @apiName GetProfile 12 | * @apiGroup Profile 13 | * 14 | * @apiParam {String} platform Platform of user. pc/xbl/psn/nintendo-switch 15 | * @apiParam {String} region Region of player. us/eu/kr/cn/global 16 | * @apiParam {String} tag BattleTag of user. Replace # with -. 17 | * @apiParam (Query String Params) {String} include Query String parameter to specifiy include filters. Comma deliminated. 18 | * @apiSuccess {Object} data Profile data. 19 | * 20 | * @apiExample {curl} Example usage: 21 | * curl -i https://owapi.io/profile/pc/us/user-12345 22 | * 23 | * @apiSuccessExample {json} Success-Response: 24 | HTTP/1.1 200 OK 25 | { 26 | "username": "Jay3", 27 | "level": 2989, 28 | "portrait": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/190aa6150e33690e39a9c91308d5da9b2e262262657af26579b95e939c44d5ad.png", 29 | "endorsement": { 30 | "sportsmanship": { 31 | "value": 0.18, 32 | "rate": 18 33 | }, 34 | "shotcaller": { 35 | "value": 0.44, 36 | "rate": 44 37 | }, 38 | "teammate": { 39 | "value": 0.38, 40 | "rate": 38 41 | }, 42 | "level": null, 43 | "frame": "https://static.playoverwatch.com/svg/icons/endorsement-frames-3c9292c49d.svg#_2", 44 | "icon": "data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjQwIiB3aWR0aD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxjaXJjbGUgcj0iMTUuOTE1NDk0MzA5MTg5NTQiIGZpbGw9IiMyYTJiMmUiIHN0cm9rZS1kYXNoYXJyYXk9IjQ0IDU2IiBzdHJva2UtZGFzaG9mZnNldD0iMjUiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlPSIjZjE5NTEyIiBjeD0iNTAlIiBjeT0iNTAlIj48L2NpcmNsZT48Y2lyY2xlIHI9IjE1LjkxNTQ5NDMwOTE4OTU0IiBmaWxsPSJ0cmFuc3BhcmVudCIgc3Ryb2tlLWRhc2hhcnJheT0iMzggNjIiIHN0cm9rZS1kYXNob2Zmc2V0PSI4MSIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2U9IiNjODFhZjUiIGN4PSI1MCUiIGN5PSI1MCUiPjwvY2lyY2xlPjxjaXJjbGUgcj0iMTUuOTE1NDk0MzA5MTg5NTQiIGZpbGw9InRyYW5zcGFyZW50IiBzdHJva2UtZGFzaGFycmF5PSIxOCA4MiIgc3Ryb2tlLWRhc2hvZmZzZXQ9IjQzIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZT0iIzQwY2U0NCIgY3g9IjUwJSIgY3k9IjUwJSI+PC9jaXJjbGU+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGR5PSIuM2VtIiBmb250LWZhbWlseT0iY2VudHVyeSBnb3RoaWMsYXJpYWwsc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9IjMwMCIgZm9udC1zaXplPSIxNiIgc3Ryb2tlPSIjZjZmNmY2IiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9IiNmNmY2ZjYiIHRleHQtYW5jaG9yPSJtaWRkbGUiPk5hTjwvdGV4dD48L3N2Zz4=" 45 | }, 46 | "private": false, 47 | "games": { 48 | "quickplay": { 49 | "won": 925, 50 | "played": 1671 51 | }, 52 | "competitive": { 53 | "won": 191, 54 | "lost": 167, 55 | "draw": 8, 56 | "played": 366, 57 | "win_rate": 53.35 58 | } 59 | }, 60 | "playtime": { 61 | "quickplay": "201:16:17", 62 | "competitive": "74:45:45" 63 | }, 64 | "competitive": { 65 | "tank": { 66 | "rank": null, 67 | "rank_img": null 68 | }, 69 | "damage": { 70 | "rank": 4429, 71 | "rank_img": "https://d1u1mce87gyfbn.cloudfront.net/game/rank-icons/rank-GrandmasterTier.png" 72 | }, 73 | "support": { 74 | "rank": 3885, 75 | "rank_img": "https://d1u1mce87gyfbn.cloudfront.net/game/rank-icons/rank-MasterTier.png" 76 | } 77 | }, 78 | "levelFrame": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/9e8600f97ea4a84d822d8b336f2b1dbfe7372fb9f2b6bf1d0336193567f6f943.png", 79 | "star": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/cd877430ccc400c10e24507dba972e24a4543edc05628045300f1349cf003f3a.png" 80 | } 81 | */ 82 | router.get('/:platform/:region/:tag', (req, res) => { 83 | 84 | const platform = req.params.platform; 85 | const region = req.params.region; 86 | const tag = req.params.tag; 87 | const include = req.query.include && req.query.include.split(',') || null; 88 | 89 | const cacheKey = `profile_${platform}_${region}_${tag}`; 90 | 91 | cache.getOrSet(cacheKey, config.CACHE_TTL, fnProfile, function(err, data) { 92 | if (err) return res.json({ message: err.message }); 93 | 94 | if (data.statusCode) { 95 | res.status(data.response.statusCode).send(data.response.statusMessage); 96 | } else { 97 | const filtered = utils.filterIncludes(include, data); 98 | res.json(filtered); 99 | } 100 | }); 101 | 102 | function fnProfile(callback) { 103 | getProfile(platform, region, tag, (err, data) => { 104 | if (err) return callback({ message: err.toString()}); 105 | return callback(err, data); 106 | }); 107 | } 108 | }); 109 | 110 | export default router; 111 | -------------------------------------------------------------------------------- /api/index.d.ts: -------------------------------------------------------------------------------- 1 | export as namespace OverwatchAPI 2 | export = OverwatchAPI 3 | 4 | declare namespace OverwatchAPI { 5 | type PLATFORM = 'pc' | 'xbl' | 'psn' 6 | type REGION = 'us' | 'eu' | 'kr' | 'cn' | 'global' 7 | 8 | function getProfile(platform: PLATFORM, region: REGION, tag: string, callback: (err: Error, data: Profile) => void): void 9 | 10 | function getStats(platform: PLATFORM, region: REGION, tag: string, callback: (err: Error, data: Stats) => void): void 11 | 12 | interface owl { 13 | getLiveMatch(callback: (err: Error, data: any) => void): void 14 | 15 | getStandings(callback: (err: Error, data: any) => void): void 16 | 17 | getSchedule(callback: (err: Error, data: any) => void): void 18 | } 19 | 20 | interface Endorsement { 21 | value: number, 22 | rate: number 23 | } 24 | 25 | interface Profile { 26 | username: string, 27 | level: number, 28 | portrait: string, 29 | endorsement: { 30 | sportsmanship: Endorsement, 31 | shotcaller: Endorsement, 32 | teammate: Endorsement, 33 | level: number, 34 | frame: string, 35 | icon: string, 36 | }, 37 | private: boolean, 38 | games: { 39 | quickplay: { 40 | won: number, 41 | played: undefined 42 | }, 43 | competitive: { 44 | won: number, 45 | lost: number, 46 | draw: number, 47 | played: number, 48 | win_rate: number 49 | } 50 | }, 51 | playtime: { 52 | quickplay: string, 53 | competitive: string 54 | }, 55 | competitive: { 56 | tank: { 57 | rank: number, 58 | rank_img: string 59 | }, 60 | damage: { 61 | rank: number, 62 | rank_img: string 63 | }, 64 | support: { 65 | rank: number, 66 | rank_img: string 67 | } 68 | }, 69 | levelFrame: string, 70 | star: string 71 | } 72 | 73 | interface Hero { 74 | hero: string, 75 | img: string 76 | } 77 | 78 | interface HeroPlaytime extends Hero { 79 | played: string, 80 | } 81 | 82 | interface HeroWins extends Hero { 83 | games_won: string, 84 | } 85 | 86 | interface HeroAccuracy extends Hero { 87 | weapon_accuracy: string, 88 | } 89 | 90 | interface HeroElimsPerLife extends Hero { 91 | eliminations_per_life: string, 92 | } 93 | 94 | interface HeroMultiKillBest extends Hero { 95 | multikill_best: string, 96 | } 97 | 98 | interface HeroObjectiveKillsAverage extends Hero { 99 | objective_kills_average: string, 100 | } 101 | 102 | interface HeroWinRate extends Hero { 103 | win_rate: string, 104 | } 105 | 106 | interface Stat { 107 | title: string, 108 | value: string 109 | } 110 | 111 | interface QuickplayCompetitiveStats { 112 | quickplay: Array, 113 | competitive: Array 114 | } 115 | 116 | interface Stats { 117 | username: string, 118 | level: number, 119 | portrait: string, 120 | endorsement: { 121 | sportsmanship: Endorsement, 122 | shotcaller: Endorsement, 123 | teammate: Endorsement, 124 | level: number, 125 | frame: string, 126 | icon: string, 127 | }, 128 | private: boolean, 129 | stats: { 130 | top_heroes: { 131 | quickplay: { 132 | played: Array, 133 | games_won: Array, 134 | weapon_accuracy: Array, 135 | eliminations_per_life: Array, 136 | multikill_best: Array, 137 | objective_kills_average: Array 138 | }, 139 | competitive: { 140 | played: Array, 141 | games_won: Array, 142 | weapon_accuracy: Array, 143 | eliminations_per_life: Array, 144 | multikill_best: Array, 145 | objective_kills_average: Array, 146 | win_rate: Array 147 | } 148 | }, 149 | combat: QuickplayCompetitiveStats, 150 | match_awards: QuickplayCompetitiveStats, 151 | assists: QuickplayCompetitiveStats, 152 | average: QuickplayCompetitiveStats, 153 | miscellaneous: QuickplayCompetitiveStats, 154 | best: QuickplayCompetitiveStats, 155 | game: QuickplayCompetitiveStats 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overwatch API 2 | An unofficial [Overwatch](https://playoverwatch.com) and [Overwatch League](https://overwatchleague.com) HTTP API and NodeJS module. 3 | 4 |

5 | 6 | [![npm](https://img.shields.io/npm/v/overwatch-api.svg)](https://www.npmjs.com/package/overwatch-api) 7 | [![Build Status](https://travis-ci.org/alfg/overwatch-api.svg?branch=master)](https://travis-ci.org/alfg/overwatch-api) 8 | 9 | ## Features 10 | * Profile Data* 11 | * Career Stats* 12 | * Overwatch League Data 13 | * Live Match 14 | * Standings 15 | * Schedule 16 | 17 | **Please note, as of the JUNE 26, 2018 patch, Career Profiles will no longer be public by default (now defaults to Friends Only). An option to make Career Profiles visible has been added under Options > Social > Profile Visibility. 18 | 19 | Your profile *MUST* be public to view most profile and career stats with this API. 20 | 21 | Source: https://playoverwatch.com/en-us/news/patch-notes/pc#patch-47946 22 | 23 | ## API Docs 24 | See: http://localhost:3000/docs/ 25 | 26 | ## NPM Module 27 | If you wish to use the Javascript API in your own project, see [api/README.md](api/README.md). 28 | 29 | ## Demo 30 | 31 | ``` 32 | curl http://localhost:3000/profile/pc/us/Jay3-11894 33 | ``` 34 | ```json 35 | { 36 | "username": "Jay3", 37 | "level": 2970, 38 | "portrait": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/190aa6150e33690e39a9c91308d5da9b2e262262657af26579b95e939c44d5ad.png", 39 | "endorsement": { 40 | "sportsmanship": { 41 | "value": 0.18, 42 | "rate": 18 43 | }, 44 | "shotcaller": { 45 | "value": 0.44, 46 | "rate": 44 47 | }, 48 | "teammate": { 49 | "value": 0.38, 50 | "rate": 38 51 | }, 52 | "level": null, 53 | "frame": "https://static.playoverwatch.com/svg/icons/endorsement-frames-3c9292c49d.svg#_2", 54 | "icon": "data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjQwIiB3aWR0aD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPjxjaXJjbGUgcj0iMTUuOTE1NDk0MzA5MTg5NTQiIGZpbGw9IiMyYTJiMmUiIHN0cm9rZS1kYXNoYXJyYXk9IjQ0IDU2IiBzdHJva2UtZGFzaG9mZnNldD0iMjUiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlPSIjZjE5NTEyIiBjeD0iNTAlIiBjeT0iNTAlIj48L2NpcmNsZT48Y2lyY2xlIHI9IjE1LjkxNTQ5NDMwOTE4OTU0IiBmaWxsPSJ0cmFuc3BhcmVudCIgc3Ryb2tlLWRhc2hhcnJheT0iMzggNjIiIHN0cm9rZS1kYXNob2Zmc2V0PSI4MSIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2U9IiNjODFhZjUiIGN4PSI1MCUiIGN5PSI1MCUiPjwvY2lyY2xlPjxjaXJjbGUgcj0iMTUuOTE1NDk0MzA5MTg5NTQiIGZpbGw9InRyYW5zcGFyZW50IiBzdHJva2UtZGFzaGFycmF5PSIxOCA4MiIgc3Ryb2tlLWRhc2hvZmZzZXQ9IjQzIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZT0iIzQwY2U0NCIgY3g9IjUwJSIgY3k9IjUwJSI+PC9jaXJjbGU+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGR5PSIuM2VtIiBmb250LWZhbWlseT0iY2VudHVyeSBnb3RoaWMsYXJpYWwsc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9IjMwMCIgZm9udC1zaXplPSIxNiIgc3Ryb2tlPSIjZjZmNmY2IiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9IiNmNmY2ZjYiIHRleHQtYW5jaG9yPSJtaWRkbGUiPk5hTjwvdGV4dD48L3N2Zz4=" 55 | }, 56 | "private": false, 57 | "games": { 58 | "quickplay": { 59 | "won": 925, 60 | "played": 1671 61 | }, 62 | "competitive": { 63 | "won": 145, 64 | "lost": 121, 65 | "draw": 4, 66 | "played": 270, 67 | "win_rate": 54.51 68 | } 69 | }, 70 | "playtime": { 71 | "quickplay": "201:16:17", 72 | "competitive": "55:14:59" 73 | }, 74 | "competitive": { 75 | "tank": { 76 | "rank": null, 77 | "rank_img": null 78 | }, 79 | "damage": { 80 | "rank": 4553, 81 | "rank_img": "https://d1u1mce87gyfbn.cloudfront.net/game/rank-icons/rank-GrandmasterTier.png" 82 | }, 83 | "support": { 84 | "rank": null, 85 | "rank_img": null 86 | } 87 | }, 88 | "levelFrame": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/9e8600f97ea4a84d822d8b336f2b1dbfe7372fb9f2b6bf1d0336193567f6f943.png", 89 | "star": "https://d15f34w2p8l1cc.cloudfront.net/overwatch/cd877430ccc400c10e24507dba972e24a4543edc05628045300f1349cf003f3a.png" 90 | } 91 | ``` 92 | 93 | ## Install 94 | 95 | #### Requirements 96 | * Node v8.0+ 97 | * Redis 98 | * Or Docker 99 | 100 | ```bash 101 | git clone https://github.com/alfg/overwatch-api.git 102 | cd overwatch-api 103 | npm install 104 | npm start 105 | ``` 106 | 107 | #### Environment Variables 108 | Set the following environment variables if you would like to override the default configuration. 109 | ``` 110 | REDIS_URL=redis://localhost:6379 111 | CACHE_TTL=3600 112 | ``` 113 | 114 | #### Docker 115 | A `docker-compose.yml` and `Dockerfile` are provided to easily setup an environment. 116 | 117 | ``` 118 | docker-compose build 119 | docker-compose up 120 | ``` 121 | 122 | #### Development 123 | This project is built using [srv](https://github.com/alfg/srv), a microservices stack based on [express](https://expressjs.com/). After installation, run the project using the following: 124 | 125 | ```bash 126 | node node_modules/srv-cli/build/srv app/index.js 127 | ``` 128 | 129 | [nodemon](https://github.com/remy/nodemon) is recommended for auto-reloading during development: 130 | ```bash 131 | nodemon node_modules/srv-cli/build/srv app/index.js 132 | ``` 133 | 134 | Generate docs with the `--docs app/routes` flag. 135 | 136 | See [srv](https://github.com/alfg/srv) documentation for more info on srv specific options. 137 | 138 | ## License 139 | MIT 140 | -------------------------------------------------------------------------------- /api/src/parser/profile.js: -------------------------------------------------------------------------------- 1 | import async from 'async'; 2 | import cheerio from 'cheerio'; 3 | import { retryRequest } from './utils'; 4 | 5 | const MAX_RETRIES = 3; 6 | 7 | // Get HTML from playoverwatch career page. 8 | function getHTML(platform, region, tag, callback) { 9 | const url = `https://overwatch.blizzard.com/en-us/career/${tag}/` 10 | const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:112.0) Gecko/20100101 Firefox/112.0'; 11 | 12 | const options = { 13 | uri: encodeURI(url), 14 | headers: { 15 | 'User-Agent': ua, 16 | }, 17 | encoding: 'utf8' 18 | } 19 | return retryRequest(options, MAX_RETRIES, callback); 20 | } 21 | 22 | // Begin html parsing. 23 | function parseHTML(results, callback) { 24 | const $ = cheerio.load(results.getHTML); 25 | 26 | // Check if profile exists. 27 | const isFound = $('.heading').text() !== 'Page Not Found'; 28 | if (!isFound) { 29 | return callback(new Error('Profile not found')); 30 | } 31 | 32 | const parsed = { 33 | user: $('.Profile-player--name').text(), 34 | portrait: $('.Profile-player--portrait').attr('src'), 35 | title: $('.Profile-player---title').text(), 36 | permission: $('.Profile-private---msg').text(), 37 | endorsementImage: $('.Profile-playerSummary--endorsement').attr('src'), 38 | quickplayWonEl: $('.stats.quickPlay-view p:contains("Games Won")').next().html(), 39 | quickplayPlayedEl: $('.stats.quickPlay-view p:contains("Games Played")').next().html(), 40 | quickplayTimePlayedEl: $('.stats.quickPlay-view p:contains("Time Played")').next().html(), 41 | compWonEl: $('.stats.competitive-view p:contains("Games Won")').next().html(), 42 | compPlayedEl: $('.stats.competitive-view p:contains("Games Played")').next().html(), 43 | compLostEl: $('.stats.competitive-view p:contains("Games Lost")').next().html(), 44 | compDrawEl: $('.stats.competitive-view p:contains("Games Tied")').next().html(), 45 | compTimePlayedEl: $('.stats.competitive-view p:contains("Time Played")').next().html(), 46 | compRankEls: $('.Profile-playerSummary--rankWrapper').find('.Profile-playerSummary--roleWrapper'), 47 | } 48 | 49 | if (parsed.compRankEls) { 50 | const r = {}; 51 | parsed.compRankEls.each((i, elem) => { 52 | const rankImgSrc = $(elem).find('img.Profile-playerSummary--rank').attr('src'); 53 | const roleImgSrc = $(elem).find('.Profile-playerSummary--role img').attr('src'); 54 | const rankParsed = rankImgSrc.split('/').pop().split('#')[0].split('-'); 55 | const role = roleImgSrc.split('/').pop().split('#')[0].split('-')[0]; 56 | 57 | const rank = `${rankParsed[0].replace('Tier', '')} ${rankParsed[1]}`; 58 | const obj = { rank, icon: rankImgSrc }; 59 | r[role] = obj; 60 | }); 61 | 62 | parsed.ranks = r; 63 | } 64 | 65 | return callback(null, parsed); 66 | } 67 | 68 | // Transform the data into a json object we can serve. 69 | function transform(results, callback) { 70 | const { parseHTML: parsed } = results; 71 | 72 | const won = {}; 73 | const lost = {}; 74 | const draw = {}; 75 | const played = {}; 76 | const time = {}; 77 | 78 | if (parsed.quickplayWonEl !== null) { 79 | won.quickplay = parsed.quickplayWonEl.trim().replace(/,/g, ''); 80 | } 81 | 82 | if (parsed.quickplayPlayedEl !== null) { 83 | played.quickplay = parsed.quickplayPlayedEl.trim().replace(/,/g, ''); 84 | } 85 | 86 | if (parsed.quickplayTimePlayedEl !== null) { 87 | time.quickplay = parsed.quickplayTimePlayedEl.trim().replace(/,/g, ''); 88 | } 89 | 90 | if (parsed.compWonEl !== null) { 91 | won.competitive = parsed.compWonEl.trim().replace(/,/g, ''); 92 | } 93 | 94 | if (parsed.compLostEl !== null) { 95 | lost.competitive = parsed.compLostEl.trim().replace(/,/g, ''); 96 | } 97 | 98 | if (parsed.compDrawEl !== null) { 99 | draw.competitive = parsed.compDrawEl.trim().replace(/,/g, ''); 100 | } 101 | 102 | if (parsed.compPlayedEl !== null) { 103 | played.competitive = parsed.compPlayedEl.trim().replace(/,/g, ''); 104 | } 105 | 106 | if (parsed.compTimePlayedEl !== null) { 107 | time.competitive = parsed.compTimePlayedEl.trim().replace(/,/g, ''); 108 | } 109 | 110 | const json = { 111 | username: parsed.user, 112 | portrait: parsed.portrait, 113 | endorsement: parsed.endorsementImage, 114 | private: parsed.permission === 'THIS PROFILE IS CURRENTLY PRIVATE', 115 | games: { 116 | quickplay: { 117 | won: parseInt(won.quickplay), 118 | played: parseInt(played.quickplay) || undefined 119 | }, 120 | competitive: { 121 | won: parseInt(won.competitive), 122 | lost: parseInt(lost.competitive), 123 | draw: parseInt(draw.competitive) || 0, 124 | played: parseInt(played.competitive), 125 | win_rate: parseFloat((parseInt(won.competitive) / (parseInt(played.competitive - parseInt(draw.competitive))) * 100).toFixed(2)), 126 | }, 127 | }, 128 | playtime: { quickplay: time.quickplay, competitive: time.competitive }, 129 | competitive: parsed.ranks, 130 | } 131 | 132 | return callback(null, json); 133 | } 134 | 135 | export default function(platform, region, tag, callback) { 136 | async.auto({ 137 | getHTML: async.apply(getHTML, platform, region, tag), 138 | parseHTML: ['getHTML', async.apply(parseHTML)], 139 | transform: ['getHTML', 'parseHTML', async.apply(transform)], 140 | }, function(err, results) { 141 | if (err) { 142 | return callback(err); 143 | } 144 | return callback(null, results.transform); 145 | }); 146 | } -------------------------------------------------------------------------------- /api/src/parser/stats.js: -------------------------------------------------------------------------------- 1 | import async from 'async'; 2 | import cheerio from 'cheerio'; 3 | import { retryRequest } from './utils'; 4 | 5 | const MAX_RETRIES = 3; 6 | 7 | // Get HTML from playoverwatch career page. 8 | function getHTML(platform, region, tag, callback) { 9 | const url = `https://overwatch.blizzard.com/en-us/career/${tag}/` 10 | const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:112.0) Gecko/20100101 Firefox/112.0'; 11 | 12 | const options = { 13 | uri: encodeURI(url), 14 | headers: { 15 | 'User-Agent': ua, 16 | }, 17 | encoding: 'utf8' 18 | } 19 | return retryRequest(options, MAX_RETRIES, callback); 20 | } 21 | 22 | // Begin html parsing. 23 | function parseHTML(results, callback) { 24 | const $ = cheerio.load(results.getHTML); 25 | 26 | // Check if profile exists. 27 | const isFound = $('.heading').text() !== 'Page Not Found'; 28 | if (!isFound) { 29 | return callback(new Error('Profile not found')); 30 | } 31 | 32 | const parsed = { 33 | user: $('.Profile-player--name').text(), 34 | portrait: $('.Profile-player--portrait').attr('src'), 35 | title: $('.Profile-player---title').text(), 36 | permission: $('.Profile-private---msg').text(), 37 | endorsementImage: $('.Profile-playerSummary--endorsement').attr('src'), 38 | } 39 | 40 | const stats = {}; 41 | 42 | // Top Heroes. 43 | const topHeroCategories = { 44 | quickplay: { 45 | 'played': '0x0860000000000021', 46 | 'games_won': '0x0860000000000039', 47 | 'weapon_accuracy': '0x086000000000002F', 48 | 'eliminations_per_life': '0x08600000000003D2', 49 | 'multikill_best': '0x0860000000000346', 50 | 'objective_kills_average': '0x086000000000039C', 51 | }, 52 | competitive: { 53 | 'played': '0x0860000000000021', 54 | 'games_won': '0x0860000000000039', 55 | 'win_rate': '0x08600000000003D1', 56 | 'weapon_accuracy': '0x086000000000002F', 57 | 'eliminations_per_life': '0x08600000000003D2', 58 | 'multikill_best': '0x0860000000000346', 59 | 'objective_kills_average': '0x086000000000039C', 60 | } 61 | }; 62 | 63 | // Quickplay. 64 | stats['top_heroes'] = { quickplay: {} }; 65 | Object.keys(topHeroCategories.quickplay).forEach((k) => { 66 | const topHeroesEls = $(`.Profile-heroSummary--view.quickPlay-view [data-category-id="${topHeroCategories.quickplay[k]}"]`) 67 | .find('.Profile-progressBar'); 68 | let topHeroes = []; 69 | topHeroesEls.each(function(i, el) { 70 | const stat = {}; 71 | stat.hero = $(this).find('.Profile-progressBar-title').text(); 72 | stat.img = $(this).find('.Profile-progressBar--icon').attr('src'); 73 | stat[k] = $(this).find('.Profile-progressBar-description').text(); 74 | topHeroes.push(stat); 75 | }); 76 | stats['top_heroes']['quickplay'][k] = topHeroes; 77 | }); 78 | 79 | // Competitive. 80 | stats['top_heroes']['competitive'] = {}; 81 | Object.keys(topHeroCategories.competitive).forEach((k) => { 82 | const topHeroesEls = $(`.Profile-heroSummary--view.competitive-view [data-category-id="${topHeroCategories.competitive[k]}"]`) 83 | .find('.Profile-progressBar'); 84 | let topHeroes = []; 85 | topHeroesEls.each(function(i, el) { 86 | const stat = {}; 87 | stat.hero = $(this).find('.Profile-progressBar-title').text(); 88 | stat.img = $(this).find('.Profile-progressBar--icon').attr('src'); 89 | stat[k] = $(this).find('.Profile-progressBar-description').text(); 90 | topHeroes.push(stat); 91 | }); 92 | stats['top_heroes']['competitive'][k] = topHeroes; 93 | }); 94 | 95 | // 96 | // Career Stats 97 | // 98 | const statCategories = [ 99 | 'Combat', 100 | 'Match Awards', 101 | 'Assists', 102 | 'Average', 103 | 'Miscellaneous', 104 | 'Best', 105 | 'Game' 106 | ]; 107 | 108 | // Quickplay Stats. 109 | statCategories.forEach(function(item) { 110 | const els = $(`.stats.quickPlay-view .option-0 .category .content .header p:contains("${item}")`).closest('.content').find('.stat-item'); 111 | let statsArr = []; 112 | els.each(function(i, el) { 113 | let stat = {}; 114 | stat.title = $(this).find('.name').text(); 115 | stat.value = $(this).find('.value').text(); 116 | statsArr.push(stat); 117 | }); 118 | item = item.replace(' ', '_').toLowerCase(); 119 | stats[item] = { quickplay: [] }; 120 | stats[item]['quickplay'] = statsArr; 121 | }); 122 | 123 | // Competitive Stats. 124 | statCategories.forEach(function(item) { 125 | const els = $(`.stats.competitive-view .option-0 .category .content .header p:contains("${item}")`).closest('.content').find('.stat-item'); 126 | let statsArr = []; 127 | els.each(function(i, el) { 128 | let stat = {}; 129 | stat.title = $(this).find('.name').text(); 130 | stat.value = $(this).find('.value').text(); 131 | statsArr.push(stat); 132 | }); 133 | item = item.replace(' ', '_').toLowerCase(); 134 | stats[item]['competitive'] = []; 135 | stats[item]['competitive'] = statsArr; 136 | }); 137 | 138 | return callback(null, { stats, parsed }); 139 | } 140 | 141 | // Transform the data into a json object we can serve. 142 | function transform(results, callback) { 143 | const { parseHTML } = results; 144 | const { stats, parsed } = parseHTML; 145 | 146 | const json = { 147 | username: parsed.user, 148 | portrait: parsed.portrait, 149 | endorsement: parsed.endorsementImage, 150 | private: parsed.permission === 'Private Profile', 151 | stats: stats 152 | } 153 | 154 | return callback(null, json); 155 | } 156 | 157 | export default function(platform, region, tag, callback) { 158 | async.auto({ 159 | getHTML: async.apply(getHTML, platform, region, tag), 160 | parseHTML: ['getHTML', async.apply(parseHTML)], 161 | transform: ['getHTML', 'parseHTML', async.apply(transform)], 162 | }, function(err, results) { 163 | if (err) { 164 | return callback(err); 165 | } 166 | return callback(null, results.transform); 167 | }); 168 | } -------------------------------------------------------------------------------- /api/src/parser/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import request from 'request'; 3 | 4 | // Credit to @relaera on https://github.com/Fuyukai/OWAPI/pull/270 for this data. 5 | const prestigeLevels = { 6 | "1055f5ae3a84b7bd8afa9fcbd2baaf9a412c63e8fe5411025b3264db12927771": 0, // Bronze Lv 1 7 | "69c2c1aff0db8429a980bad7db76a3388003e43f0034097dc4cfa7f13c5de7d7": 0, // Bronze Lv 11 8 | "4d63c2aadf536e87c84bdb7157c7b688cffb286e17a5362d2fa5c5281f4fc2a2": 0, // Bronze Lv 21 9 | "78ebb45dd26b0050404305fdc1cb9ddc311d2c7e62400fd6348a3a488c69eee7": 0, // Bronze Lv 31 10 | "888c84f2dfd211cde0c595036574040ca96b1698578daab90ce6822d89f7fe0e": 0, // Bronze Lv 41 11 | "3fdfdd16c34ab7cdc9b7be3c04197e900928b368285ce639c1d3e1c0619eea6d": 0, // Bronze Lv 51 12 | "e8b7df4b88998380658d49d00e7bc483c740432ac417218e94fab4137bec4ae0": 0, // Bronze Lv 61 13 | "45cc69ca29f3981fa085b5337d2303a4eb555853daae1c29351b7ba46b27bbcd": 0, // Bronze Lv 71 14 | "8b4be1017beff0bcd1f7a48d8cdf7faf9f22c1ffd2bdeaaff2684da5cddeaa76": 0, // Bronze Lv 81 15 | "1b00b8cab530e98c378de2f3e8834d92ee41b4cd7b118179a8ecbccee83c8104": 0, // Bronze Lv 91 16 | 17 | "f5d80c8b7370cda9a491bdf89e02bcd8c6ba1708189d907c7e4f55a719030264": 6, // Silver Lv 1 18 | "ddb6f3f79241b8af2fa77b52910f60a2332db5d8347b3039d1328ae6d1272a59": 6, // Silver Lv 11 19 | "c59072a340e6187116f5ae7456674dd6e1cba4b15781922d63fb94f56d9539c0": 6, // Silver Lv 21 20 | "624461e537900ce98e3178d1a298cba4830c14f6a81a8b36319da6273bed255a": 6, // Silver Lv 31 21 | "ba68d2c0f1b55e1991161cb1f88f369b97311452564b200ea1da226eb493e2e8": 6, // Silver Lv 41 22 | "3c078f588353feeb3f52b0198fade12a78573a01c53050aca890969a395ff66a": 6, // Silver Lv 51 23 | "f9bc9c6bb95f07f4e882b9e003ba7fa5ca6552fb8e0c27473a8b031714670116": 6, // Silver Lv 61 24 | "8aa9f56cdd250579dd8b0ce6bd835934fffe8c27b9ce609f046c19a4a81591f8": 6, // Silver Lv 71 25 | "32f84a58719318fa0aeee530ed3240952ba9945b998cd9e8150ebb583db0d4f6": 6, // Silver Lv 81 26 | "c95fa44c02a1eae89a7c8d503026f181f1cc565da93d47c6254fab2c3d8793ef": 6, // Silver Lv 91 27 | 28 | "5ab5c29e0e1e33f338ae9afc37f51917b151016aef42d10d361baac3e0965df1": 12, // Gold Lv 1 29 | "7fd73e680007054dbb8ac5ea8757a565858b9d7dba19f389228101bda18f36b0": 12, // Gold Lv 11 30 | "0ada1b8721830853d3fbcfabf616e1841f2100279cff15b386093f69cc6c09ad": 12, // Gold Lv 21 31 | "7095ee84fc0a3aaac172120ffe0daa0d9abca33112e878cd863cd925cd8404b6": 12, // Gold Lv 31 32 | "fa410247dd3f5b7bf2eb1a65583f3b0a3c8800bcd6b512ab1c1c4d9dd81675ae": 12, // Gold Lv 41 33 | "a938ef37b673a240c4ade00d5a95f330b1e1ba93da9f0d3754bdb8a77bbbd7a1": 12, // Gold Lv 51 34 | "49afee29dc05547ceebe6c1f61a54f7105a0e1b7f2c8509ff2b4aeaf4d384c8e": 12, // Gold Lv 61 35 | "2c1464fb96d38839281c0bdb6e1a0cd06769782a5130609c13f6ca76fa358bcf": 12, // Gold Lv 71 36 | "98f6eea1a2a10576251d6c690c13d52aaac19b06811ed2b684b43e7a9318f622": 12, // Gold Lv 81 37 | "6e1036eab98de41694d785e076c32dbabe66962d38325117436b31210b003ad4": 12, // Gold Lv 91 38 | 39 | "69fde7abebb0bb5aa870e62362e84984cae13e441aec931a5e2c9dc5d22a56dc": 18, // Platinum Lv 1 40 | "9c84055f9d91a297ccd1bac163c144e52bcce981dc385ff9e2957c5bd4433452": 18, // Platinum Lv 11 41 | "97c803711cddc691bc458ec83dec73c570b0cc07219632c274bb5c5534786984": 18, // Platinum Lv 21 42 | "c562ec882ababf2030e40ad3ce27e38176899f732166a1b335fd8f83735261f3": 18, // Platinum Lv 31 43 | "da2cb4ab3281329c367cea51f9438c3d20d29ee07f55fa65762481777663f7f9": 18, // Platinum Lv 41 44 | "460670e4d61b9bf0bcde6d93a52e50f01541177a20aaf69bbda91fe4353ed2b0": 18, // Platinum Lv 51 45 | "5a019024b384de73f4348ed981ae58ec458a7ae6db68e0c44cda4d7062521b04": 18, // Platinum Lv 61 46 | "1d5a458ecaf00fe0ef494b4159412d30a4b58ee76b9f0ff44b1db14ed211273c": 18, // Platinum Lv 71 47 | "f1d43d87bbe5868cb99062ac02099001dd9f8215831347d8978e895468e81ef6": 18, // Platinum Lv 81 48 | "27b2d05f97179aae72c8f72b69978777e1c5022f77e84f28e5943be8e9cd1d49": 18, // Platinum Lv 91 49 | 50 | "5c83959aa079f9ed9fd633411289920568e616c5117b2a7bb280dd8c857f8406": 24, // Diamond Lv 1 51 | "ac14208753baf77110880020450fa4aa0121df0c344c32a2d20f77c18ba75db5": 24, // Diamond Lv 11 52 | "a42bcb3339e1b3c999fc2799b0787fd862e163ec504d7541fa3ea8893b83957a": 24, // Diamond Lv 21 53 | "7f1cc30ed6981974b6950666bb8236a6aa7b5a8579b14969394212dd7fa2951d": 24, // Diamond Lv 31 54 | "efe3ab1c85c6266199ac7539566d4c811b0ee17bc5fb3e3e7a48e9bc2473cf50": 24, // Diamond Lv 41 55 | "c7b9df20c91b10dc25bfdc847d069318ed9e8e69c5cad760803470caa9576e48": 24, // Diamond Lv 51 56 | "413bdc1e11f9b190ed2c6257a9f7ea021fd9fcef577d50efcf30a5ea8df989a4": 24, // Diamond Lv 61 57 | "625645c3c9af49eb315b504dba32137bb4081d348ec5b9750196b0ec0c9bb6a6": 24, // Diamond Lv 71 58 | "f9813603e19350bb6d458bbee3c8c2a177b6503e6ff54868e8d176fa424a0191": 24, // Diamond Lv 81 59 | "9e8600f97ea4a84d822d8b336f2b1dbfe7372fb9f2b6bf1d0336193567f6f943": 24, // Diamond Lv 91 / Max 60 | } 61 | 62 | const prestigeStars = { 63 | "8de2fe5d938256a5725abe4b3655ee5e9067b7a1f4d5ff637d974eb9c2e4a1ea": 1, // 1 Bronze star 64 | "755825d4a6768a22de17b48cfbe66ad85a54310ba5a8f8ab1e9c9a606b389354": 2, // 2 Bronze stars 65 | "4a2c852a16043f613b7bfac33c8536dd9f9621a3d567174cb4ad9a80e3b13102": 3, // 3 Bronze stars 66 | "bc80149bbd78d2f940984712485bce23ddaa6f2bd0edd1c0494464ef55251eef": 4, // 4 Bronze stars 67 | "d35d380b7594b8f6af2d01040d80a5bfb6621553406c0905d4764bdc92a4ede8": 5, // 5 Bronze stars 68 | 69 | "426c754c76cd12e6aacd30293a67363571341eea37880df549d3e02015a588fe": 1, // 1 Silver star 70 | "c137dd97008328ed94efc5a9ec446e024c9ac92fce89fa5b825c5b1d7ff8d807": 2, // 2 Silver stars 71 | "9a7c57aee22733a47c2b562000861d687d0423a74eb5e609c425f10db5528ed9": 3, // 3 Silver stars 72 | "b944cf1de6653b629c951fd14583069bc91b1f1b7efdb171203448b2dbc39917": 4, // 4 Silver stars 73 | "9b838b75065248ec14360723e4caf523239128ff8c13bda36cfd0b59ef501c1e": 5, // 5 Silver stars 74 | 75 | "1858704e180db3578839aefdb83b89054f380fbb3d4c46b3ee12d34ed8af8712": 1, // 1 Gold/Platinum star 76 | "e8568b9f9f5cac7016955f57c7b192ccd70f7b38504c7849efa8b1e3f7a1b077": 2, // 2 Gold/Platinum stars 77 | "a25388825a0e00c946a23f5dd74c5b63f77f564231e0fd01e42ff2d1c9f10d38": 3, // 3 Gold/Platinum stars 78 | "cff520765f143c521b25ad19e560abde9a90eeae79890b14146a60753d7baff8": 4, // 4 Gold/Platinum stars 79 | "35fd7b9b98f57389c43e5a8e7ca989ca593c9f530985adf4670845bb598e1a9d": 5, // 5 Gold/Platinum stars 80 | 81 | "8033fa55e3de5e7655cd694340870da851cdef348d7dcb76411f3a9c2c93002c": 1, // 1 Diamond star 82 | "605c201cf3f0d24b318f643acb812084ff284e660f2bb5d62b487847d33fad29": 2, // 2 Diamond stars 83 | "1c8c752d0f2757dc0bcc9e3db76f81c3802c874164a3b661475e1c7bd67c571f": 3, // 3 Diamond stars 84 | "58b1323ab2eb1298fa6be649a8d4d7f0e623523bd01964ed8fefd5175d9073c0": 4, // 4 Diamond stars 85 | "cd877430ccc400c10e24507dba972e24a4543edc05628045300f1349cf003f3a": 5, // 5 Diamond stars 86 | } 87 | 88 | export function getPrestigeLevel(val) { 89 | if (prestigeLevels[val]) { 90 | return prestigeLevels[val]; 91 | } 92 | return 0; 93 | } 94 | 95 | export function getPrestigeStars(val) { 96 | if (prestigeStars[val]) { 97 | return prestigeStars[val]; 98 | } 99 | return 0; 100 | } 101 | 102 | export function retryRequest(options, retries = 3, callback) { 103 | request(options, (err, res, body) => { 104 | if (res.statusCode === 200) return callback(null, body); 105 | 106 | // Do retry if status is unsuccessful. 107 | if (res.statusCode !== 200 && retries > 0) { 108 | console.error(`got status: ${res.statusCode} for uri: ${options.uri}. retries: ${retries}`) 109 | return retryRequest(options, retries - 1, callback); 110 | } else { 111 | return callback(new Error('Profile not found')); 112 | } 113 | }); 114 | } --------------------------------------------------------------------------------