├── chrome-extension ├── options.html ├── icons │ ├── web.png │ ├── github.png │ ├── icon128.png │ ├── icon16.png │ ├── icon32.png │ ├── icon48.png │ └── icon64.png ├── css │ └── main.css ├── popup.html ├── manifest.json ├── background.js └── foreground.js ├── .gitignore ├── .vscode └── settings.json ├── public ├── favicon.ico ├── robots.txt ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest ├── js │ └── darkmode.js └── stylesheets │ ├── tablestyle.css │ ├── darkmode.css │ └── main.css ├── data └── screenshots │ ├── icon.png │ ├── web_demo.gif │ ├── bull-dashboard1.png │ ├── bull-dashboard2.png │ └── extension_demo.gif ├── services ├── predict-addon │ ├── addon.js │ ├── build │ │ ├── Release │ │ │ ├── predict_addon.node │ │ │ ├── obj.target │ │ │ │ ├── predict_addon.node │ │ │ │ └── predict_addon │ │ │ │ │ └── cpp │ │ │ │ │ └── main.o │ │ │ └── .deps │ │ │ │ └── Release │ │ │ │ ├── predict_addon.node.d │ │ │ │ └── obj.target │ │ │ │ ├── predict_addon.node.d │ │ │ │ └── predict_addon │ │ │ │ └── cpp │ │ │ │ └── main.o.d │ │ ├── binding.Makefile │ │ ├── predict_addon.target.mk │ │ ├── config.gypi │ │ └── Makefile │ ├── binding.gyp │ ├── package.json │ ├── cpp │ │ ├── main.cpp │ │ ├── helpers.h │ │ └── predict.h │ └── test │ │ └── test.js ├── redis.js ├── predict.js ├── job-queues │ ├── contestPredictionQueue.js │ └── jobScheduler.js ├── contests.js └── users.js ├── web.js ├── views ├── errors │ └── 404.ejs ├── login.ejs ├── layouts │ └── layout.ejs ├── index.ejs └── ranking.ejs ├── .env.example ├── routes ├── api.js ├── index.js └── auth.js ├── .github └── FUNDING.yml ├── models ├── user.js └── contest.js ├── LICENSE ├── background.js ├── package.json ├── helpers.js ├── controllers ├── predictionsController.js ├── sitemapController.js └── rankingsController.js ├── tests └── ratelimit.test.js ├── main.js └── README.md /chrome-extension/options.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | .snyk 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "iostream": "cpp" 4 | } 5 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | Sitemap: https://lcpredictor.onrender.com/sitemap.xml 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /data/screenshots/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/data/screenshots/icon.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /services/predict-addon/addon.js: -------------------------------------------------------------------------------- 1 | const addon = require("./build/Release/predict_addon"); 2 | module.exports = addon; 3 | -------------------------------------------------------------------------------- /data/screenshots/web_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/data/screenshots/web_demo.gif -------------------------------------------------------------------------------- /chrome-extension/icons/web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/web.png -------------------------------------------------------------------------------- /chrome-extension/icons/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/github.png -------------------------------------------------------------------------------- /chrome-extension/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/icon128.png -------------------------------------------------------------------------------- /chrome-extension/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/icon16.png -------------------------------------------------------------------------------- /chrome-extension/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/icon32.png -------------------------------------------------------------------------------- /chrome-extension/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/icon48.png -------------------------------------------------------------------------------- /chrome-extension/icons/icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/chrome-extension/icons/icon64.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /data/screenshots/bull-dashboard1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/data/screenshots/bull-dashboard1.png -------------------------------------------------------------------------------- /data/screenshots/bull-dashboard2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/data/screenshots/bull-dashboard2.png -------------------------------------------------------------------------------- /data/screenshots/extension_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/data/screenshots/extension_demo.gif -------------------------------------------------------------------------------- /services/predict-addon/build/Release/predict_addon.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/services/predict-addon/build/Release/predict_addon.node -------------------------------------------------------------------------------- /services/predict-addon/build/binding.Makefile: -------------------------------------------------------------------------------- 1 | # This file is generated by gyp; do not edit. 2 | 3 | export builddir_name ?= ./build/. 4 | .PHONY: all 5 | all: 6 | $(MAKE) predict_addon 7 | -------------------------------------------------------------------------------- /services/predict-addon/binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "target_name": "predict_addon", 5 | "sources": ["./cpp/main.cpp"], 6 | }, 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /services/predict-addon/build/Release/obj.target/predict_addon.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/services/predict-addon/build/Release/obj.target/predict_addon.node -------------------------------------------------------------------------------- /services/predict-addon/build/Release/obj.target/predict_addon/cpp/main.o: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SysSn13/leetcode-rating-predictor/HEAD/services/predict-addon/build/Release/obj.target/predict_addon/cpp/main.o -------------------------------------------------------------------------------- /services/predict-addon/build/Release/.deps/Release/predict_addon.node.d: -------------------------------------------------------------------------------- 1 | cmd_Release/predict_addon.node := rm -rf "Release/predict_addon.node" && cp -af "Release/obj.target/predict_addon.node" "Release/predict_addon.node" 2 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /web.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const app = express(); 3 | app.use(express.urlencoded({ extended: true })); 4 | const indexRouter = require("./routes/index"); 5 | 6 | const router = express.Router(); 7 | router.use("/", indexRouter); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /chrome-extension/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Open Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Arial, sans-serif; 3 | font-weight: 400; 4 | min-width: 320px; 5 | padding: 0; 6 | margin: 0; 7 | } 8 | .icon{ 9 | max-width: 32px; 10 | } -------------------------------------------------------------------------------- /services/predict-addon/build/Release/.deps/Release/obj.target/predict_addon.node.d: -------------------------------------------------------------------------------- 1 | cmd_Release/obj.target/predict_addon.node := g++ -shared -pthread -rdynamic -m64 -Wl,-soname=predict_addon.node -o Release/obj.target/predict_addon.node -Wl,--start-group Release/obj.target/predict_addon/cpp/main.o -Wl,--end-group 2 | -------------------------------------------------------------------------------- /views/errors/404.ejs: -------------------------------------------------------------------------------- 1 |
2 |

3 | 404 4 |

5 |
6 |

7 | This page does not exist. 8 |

9 |
10 | Back to home 11 |
-------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=mongodb://localhost 2 | 3 | WEB=1 4 | RATE_LIMIT_WINDOW=10000 5 | RATE_LIMIT=50 6 | 7 | API_DISABLED=0 8 | API_RATE_LIMIT_WINDOW=10000 9 | API_RATE_LIMIT=20 10 | 11 | BACKGROUND=0 12 | REDIS_URL=redis://127.0.0.1:6379 13 | THREAD_CNT=4 14 | 15 | BULLBOARD_USERNAME=bull-board 16 | BULLBOARD_PASS=admin 17 | SESSION_SECRET=keyboard-key -------------------------------------------------------------------------------- /services/predict-addon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "predict-addon", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "addon.js", 6 | "scripts": { 7 | "test": "node ./test/test.js", 8 | "build":"node-gyp clean && node-gyp configure && node-gyp build" 9 | }, 10 | "keywords": [], 11 | "author": "Sudesh Chaudhary", 12 | "license": "MIT" 13 | } 14 | -------------------------------------------------------------------------------- /views/login.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Login to bull-board

3 |
4 | 5 | 6 | 7 |
8 |
-------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const predictionsController = require("../controllers/predictionsController.js"); 3 | router.get("/ping", function (req, res) { 4 | res.json({ 5 | status: "OK", 6 | message: "pong", 7 | }); 8 | }); 9 | 10 | router.route("/predictions").get(predictionsController.get); 11 | module.exports = router; 12 | -------------------------------------------------------------------------------- /public/js/darkmode.js: -------------------------------------------------------------------------------- 1 | var preference = JSON.parse(localStorage.getItem("dark")); 2 | if (preference === null) { 3 | localStorage.setItem("dark", false); 4 | preference=false; 5 | } 6 | var checkBox = document.getElementById("checkDark"); 7 | if(checkBox){ 8 | checkBox.checked = preference; 9 | } 10 | const html = document.getElementsByTagName("html")[0]; 11 | 12 | if (preference) { 13 | html.style.filter = "invert(0.9) hue-rotate(150deg)"; 14 | } else { 15 | html.style.filter = ""; 16 | } 17 | 18 | function toggle_darkmode() { 19 | preference = !preference; 20 | checkBox.checked = preference; 21 | localStorage.setItem("dark", preference); 22 | 23 | if (preference) { 24 | html.style.filter = "invert(0.9) hue-rotate(150deg)"; 25 | } else { 26 | html.style.filter = ""; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /services/redis.js: -------------------------------------------------------------------------------- 1 | const REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; 2 | 3 | const Redis = require("ioredis"); 4 | const client = new Redis(REDIS_URL); 5 | const subscriber = new Redis(REDIS_URL); 6 | 7 | const opts = { 8 | // redisOpts here will contain at least a property of connectionName which will identify the queue based on its name 9 | createClient: function (type, redisOpts) { 10 | switch (type) { 11 | case "client": 12 | return client; 13 | case "subscriber": 14 | return subscriber; 15 | case "bclient": 16 | return new Redis(REDIS_URL, redisOpts); 17 | default: 18 | throw new Error("Unexpected connection type: ", type); 19 | } 20 | }, 21 | }; 22 | module.exports = opts; 23 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: leetcode-rating-predictor # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const Contest = require("../models/contest"); 3 | const router = express.Router(); 4 | const rankingsController = require("../controllers/rankingsController"); 5 | const sitemapController = require("../controllers/sitemapController"); 6 | 7 | router.get("/", async (req, res) => { 8 | try { 9 | let contests = await Contest.find({}, { rankings: 0 }).sort({ 10 | startTime: "desc", 11 | }); 12 | 13 | res.render("index", { 14 | contests: contests, 15 | title: "Leetcode Rating Predictor", 16 | }); 17 | } catch (err) { 18 | console.error(err); 19 | res.sendStatus(500); 20 | } 21 | }); 22 | 23 | router.get("/contest/:contestSlug/ranking/:page", rankingsController.get); 24 | router.post("/contest/:contestSlug/ranking/search", rankingsController.search); 25 | router.get("/sitemap.xml", sitemapController.get); 26 | module.exports = router; 27 | -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | 3 | const contestHistorySchema = new mongoose.Schema({ 4 | _id: String, 5 | title: String, 6 | startTime: Number, 7 | rating: { 8 | type: Number, 9 | default:1500, 10 | }, 11 | ranking:{ 12 | type:Number, 13 | default: 0 14 | }, 15 | }) 16 | const userSchema = new mongoose.Schema({ 17 | _id:{ 18 | type: String 19 | }, 20 | attendedContestsCount:{ 21 | type: Number, 22 | default: 0 23 | }, 24 | rating:{ 25 | type: Number, 26 | default: 1500 27 | }, 28 | globalRanking:{ 29 | type: Number, 30 | default:0 31 | }, 32 | contestsHistory: [contestHistorySchema], 33 | lastUpdated:{ 34 | type: Date, 35 | default: Date.now, 36 | }, 37 | }) 38 | 39 | exports.User = mongoose.model("User",userSchema) 40 | exports.ContestHistory = mongoose.model("ContestHistory",contestHistorySchema) -------------------------------------------------------------------------------- /chrome-extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LC Predictor 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | Leetcode rating predictor 15 | 16 |
17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const passport = require('passport'); 5 | const LocalStrategy = require('passport-local').Strategy; 6 | 7 | passport.use( 8 | new LocalStrategy(function (username, password, cb) { 9 | if (username === process.env.BULLBOARD_USERNAME && password === process.env.BULLBOARD_PASS) { 10 | return cb(null, {user : process.env.BULLBOARD_USERNAME}); 11 | } 12 | return cb(null, false); 13 | }) 14 | ); 15 | 16 | passport.serializeUser((user, cb) => { 17 | cb(null, user); 18 | }); 19 | 20 | passport.deserializeUser((user, cb) => { 21 | cb(null, user); 22 | }); 23 | 24 | router.get('/', (req, res) => { 25 | res.render('login', { 26 | title : 'Login' 27 | }); 28 | }); 29 | 30 | router.post('/', 31 | passport.authenticate('local', { failureRedirect: '/login' }), 32 | function(req, res){ 33 | res.redirect('/bull-board'); 34 | } 35 | ); 36 | 37 | module.exports = router; 38 | -------------------------------------------------------------------------------- /chrome-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "LC Predictor", 4 | "version": "1.0.1", 5 | 6 | "short_name": "LC Predictor", 7 | "background": { 8 | "service_worker": "background.js" 9 | }, 10 | "action": { 11 | "default_popup": "./popup.html", 12 | "icons": { 13 | "16": "/icons/icon16.png", 14 | "32": "/icons/icon32.png", 15 | "48": "/icons/icon48.png", 16 | "64": "/icons/icon64.png", 17 | "128": "/icons/icon128.png" 18 | } 19 | }, 20 | "description": "Browser extension for predicting leetcode contest rating. It shows approximate rating delta after contests on leetcode itself.", 21 | "icons": { 22 | "16": "/icons/icon16.png", 23 | "32": "/icons/icon32.png", 24 | "48": "/icons/icon48.png", 25 | "64": "/icons/icon64.png", 26 | "128": "/icons/icon128.png" 27 | }, 28 | "options_page": "./options.html", 29 | "permissions": [ 30 | "tabs", 31 | "storage", 32 | "scripting" 33 | ], 34 | "host_permissions": [ 35 | "https://leetcode.com/*", 36 | "https://lcpredictor.onrender.com/*" 37 | ] 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sudesh Chaudhary 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 | -------------------------------------------------------------------------------- /services/predict-addon/cpp/main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include "helpers.h" 5 | 6 | using namespace v8; 7 | using namespace std; 8 | 9 | void PredictRatings(const FunctionCallbackInfo &args){ 10 | Isolate* isolate = args.GetIsolate(); 11 | Local context = isolate->GetCurrentContext(); 12 | vector rankList = unpackRankList(isolate,args); 13 | int THREAD_CNT = 1; 14 | if(args.Length()>1){ 15 | if(!args[1]->IsNumber()){ 16 | isolate->ThrowException( 17 | Exception::TypeError( 18 | String::NewFromUtf8( 19 | isolate,"Wrong arguments. THREAD_CNT must be a Number." 20 | ).ToLocalChecked() 21 | ) 22 | ); 23 | } 24 | THREAD_CNT = max(THREAD_CNT,int(args[1]->NumberValue(context).FromMaybe(1))); 25 | } 26 | Predict(rankList,THREAD_CNT); 27 | Local predictedRatings = packRankList(rankList); 28 | args.GetReturnValue().Set(predictedRatings); 29 | } 30 | 31 | void init(Local exports) { 32 | NODE_SET_METHOD(exports,"predict",PredictRatings); 33 | } 34 | 35 | NODE_MODULE(predict_addon, init) -------------------------------------------------------------------------------- /models/contest.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const Schema = mongoose.Schema; 3 | 4 | const rankingSchema = new Schema({ 5 | _id: String, 6 | user_slug: String, 7 | country_code: String, 8 | country_name: String, 9 | data_region: { 10 | type: String, 11 | default: "US", 12 | }, 13 | rank: Number, 14 | current_rating: { 15 | type: Number, 16 | default: null, 17 | }, 18 | delta: { 19 | type: Number, 20 | default: null, 21 | }, 22 | }); 23 | const ContestRankingsSchema = new Schema({ 24 | _id: String, 25 | title: String, 26 | startTime: Date, 27 | endTime: Date, 28 | contest_id: Number, 29 | user_num: Number, 30 | rankings_fetched: { 31 | type: Boolean, 32 | default: false, 33 | }, 34 | users_fetched: { 35 | type: Boolean, 36 | default: false, 37 | }, 38 | ratings_predicted: { 39 | type: Boolean, 40 | default: false, 41 | }, 42 | rankings: [rankingSchema], 43 | refetch_rankings:{ 44 | type:Boolean, 45 | default:false, 46 | }, 47 | lastUpdated: { 48 | type: Date, 49 | }, 50 | }); 51 | 52 | module.exports = mongoose.model("Contest", ContestRankingsSchema); 53 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | const predictQueue = require("./services/job-queues/contestPredictionQueue"); 2 | const jobScheduler = require("./services/job-queues/jobScheduler"); 3 | const { createBullBoard } = require("@bull-board/api"); 4 | const { BullAdapter } = require("@bull-board/api/bullAdapter"); 5 | const { ExpressAdapter } = require("@bull-board/express"); 6 | 7 | const serverAdapter = new ExpressAdapter(); 8 | 9 | const bullBoard = createBullBoard({ 10 | queues: [new BullAdapter(predictQueue), new BullAdapter(jobScheduler)], 11 | serverAdapter: serverAdapter, 12 | }); 13 | 14 | serverAdapter.setBasePath("/bull-board"); 15 | 16 | const initScheduler = async () => { 17 | await jobScheduler.add("contestScheduler", {}); 18 | await jobScheduler.add("updateUserDataScheduler", { 19 | rateLimit: 3, 20 | limit: 1000, 21 | }); 22 | 23 | // repeat contestScheduler every day at midnight 24 | await jobScheduler.add( 25 | "contestScheduler", 26 | {}, 27 | { repeat: { cron: "0 0 * * *" } } 28 | ); 29 | 30 | // Repeat updateUserDataScheduler every 4 hours 31 | await jobScheduler.add( 32 | "updateUserDataScheduler", 33 | { rateLimit: 3, limit: 1000 }, 34 | { repeat: { cron: "0 */4 * * *" } } 35 | ); 36 | }; 37 | 38 | initScheduler(); 39 | 40 | module.exports.bullBoardServerAdapter = serverAdapter; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leetcode-rating-predictor", 3 | "version": "1.0.0", 4 | "description": "", 5 | "engines": { 6 | "node": "14.17.3" 7 | }, 8 | "main": "server.js", 9 | "scripts": { 10 | "start": "node main.js --max_old_space_size=1024 --trace-warnings", 11 | "dev": "nodemon --max_old_space_size=1024 --trace-warnings main.js", 12 | "buildAddon": "npm run build --prefix ./services/predict-addon/", 13 | "test": "jest" 14 | }, 15 | "keywords": [], 16 | "author": "Sudesh Chaudhary (https://github.com/SysSn13)", 17 | "license": "MIT", 18 | "dependencies": { 19 | "@bull-board/express": "^3.11.1", 20 | "body-parser": "^1.20.2", 21 | "bottleneck": "^2.19.5", 22 | "bull": "^3.27.0", 23 | "connect-ensure-login": "^0.1.1", 24 | "ejs": "^3.1.9", 25 | "express": "^4.18.3", 26 | "express-ejs-layouts": "^2.5.0", 27 | "express-rate-limit": "^5.3.0", 28 | "express-session": "^1.17.2", 29 | "express-unless": "^1.0.0", 30 | "mongoose": "^5.13.22", 31 | "node-fetch": "^2.6.7", 32 | "node-gyp": "^8.1.0", 33 | "passport": "^0.7.0", 34 | "passport-local": "^1.0.0", 35 | "sitemap": "^7.1.1" 36 | }, 37 | "_id": "leetcode-rating-predictor@1.0.0", 38 | "devDependencies": { 39 | "dotenv": "^10.0.0", 40 | "jest": "^27.0.6", 41 | "nodemon": "^2.0.7" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/stylesheets/tablestyle.css: -------------------------------------------------------------------------------- 1 | table td, 2 | table th { 3 | text-overflow: ellipsis; 4 | white-space: nowrap; 5 | overflow: hidden; 6 | } 7 | 8 | .card { 9 | border-radius: .5rem; 10 | } 11 | 12 | .table-scroll { 13 | border-radius: .5rem; 14 | } 15 | 16 | .table-scroll table thead th { 17 | font-size: 1.25rem; 18 | } 19 | 20 | .pagination>li>a { 21 | background-color: white; 22 | color: #000000; 23 | } 24 | 25 | .pagination>li>a:focus, 26 | .pagination>li>a:hover, 27 | .pagination>li>span:focus, 28 | .pagination>li>span:hover { 29 | color: #5a5a5a; 30 | background-color: #eee; 31 | border-color: #ddd; 32 | } 33 | 34 | .pagination>.active>a { 35 | color: #000000; 36 | background-color: #337ab7 !important; 37 | border: solid 1px #337ab7 !important; 38 | } 39 | 40 | .pagination>.active>a:hover { 41 | background-color: #4c85b8 !important; 42 | border: solid 1px #4c85b8; 43 | } 44 | 45 | table, 46 | tbody, 47 | td, 48 | tfoot, 49 | th, 50 | thead, 51 | tr { 52 | border-style: none !important; 53 | } 54 | 55 | table { 56 | border-radius: .4em; 57 | overflow: hidden; 58 | } 59 | 60 | .table thead th { 61 | border-bottom: 2px solid #6685a5 !important; 62 | } 63 | 64 | .dataTables_filter { 65 | margin-bottom: 1em !important; 66 | padding: 1px; 67 | } -------------------------------------------------------------------------------- /public/stylesheets/darkmode.css: -------------------------------------------------------------------------------- 1 | html { 2 | transition: color 300ms, background-color 300ms; 3 | } 4 | .switch { 5 | position: relative; 6 | display: inline-block; 7 | width: 60px; 8 | height: 34px; 9 | } 10 | 11 | @media screen and (max-width: 600px) { 12 | .switch { 13 | width: 80px; 14 | } 15 | } 16 | 17 | .switch input { 18 | opacity: 0; 19 | width: 0; 20 | height: 0; 21 | } 22 | 23 | .slider { 24 | position: absolute; 25 | cursor: pointer; 26 | top: 0; 27 | left: 0; 28 | right: 0; 29 | bottom: 0; 30 | background-color: #ccc; 31 | -webkit-transition: .4s; 32 | transition: .4s; 33 | } 34 | 35 | .slider:before { 36 | position: absolute; 37 | content: ""; 38 | height: 26px; 39 | width: 26px; 40 | left: 4px; 41 | bottom: 4px; 42 | background-color: white; 43 | -webkit-transition: .4s; 44 | transition: .4s; 45 | } 46 | 47 | input:checked + .slider { 48 | background-color: #2196F3; 49 | } 50 | 51 | input:focus + .slider { 52 | box-shadow: 0 0 1px #2196F3; 53 | } 54 | 55 | input:checked + .slider:before { 56 | -webkit-transform: translateX(26px); 57 | -ms-transform: translateX(26px); 58 | transform: translateX(26px); 59 | } 60 | 61 | /* Rounded sliders */ 62 | .slider.round { 63 | border-radius: 34px; 64 | } 65 | 66 | .slider.round:before { 67 | border-radius: 50%; 68 | } 69 | 70 | .toggle-container{ 71 | display: flex; 72 | justify-content: center; 73 | } 74 | 75 | .toggle-container { 76 | display: flex; 77 | align-items: center; 78 | 79 | em { 80 | margin-left: 10px; 81 | font-size: 1rem; 82 | } 83 | } -------------------------------------------------------------------------------- /helpers.js: -------------------------------------------------------------------------------- 1 | exports.getUserId = (username, dataRegion = "US") => { 2 | return dataRegion + "/" + username.trim().toLowerCase(); 3 | }; 4 | 5 | exports.convertDateYYYYMMDD = (date) => { 6 | var yyyy = date.getFullYear().toString(); 7 | var mm = (date.getMonth() + 1).toString(); 8 | var dd = date.getDate().toString(); 9 | 10 | var mmChars = mm.split(""); 11 | var ddChars = dd.split(""); 12 | 13 | return ( 14 | yyyy + 15 | "-" + 16 | (mmChars[1] ? mm : "0" + mmChars[0]) + 17 | "-" + 18 | (ddChars[1] ? dd : "0" + ddChars[0]) 19 | ); 20 | }; 21 | 22 | exports.IsLatestContest = (time) => { 23 | return Date.now() - time <= 2 * 24 * 60 * 60 * 1000; 24 | }; 25 | 26 | exports.getRemainingTime = (time) => { 27 | return Math.max(0, Math.ceil(time - Date.now())); 28 | }; 29 | 30 | exports.isNumeric = (value) => { 31 | return /^\d+$/.test(value); 32 | }; 33 | 34 | exports.generatePagination = (total,current)=>{ 35 | let result = [] 36 | let rem = 5; 37 | let start = Math.max(current-(2+Math.max(0,2-(total-current))),1); 38 | if(start>1){ 39 | result.push(1); 40 | } 41 | if(start>2){ 42 | result.push(-1); 43 | } 44 | for(let i=start;i<=current;i++){ 45 | result.push(i); 46 | } 47 | rem -= current-start+1; 48 | for(let i=current+1;i<=current+rem && i<=total;i++){ 49 | result.push(i); 50 | } 51 | if(current+rem { 14 | return handle.trim(); 15 | }) 16 | .filter((handle) => handle != ""); 17 | handles.length = Math.min(handles.length, 50); 18 | 19 | Contest.aggregate( 20 | [ 21 | { 22 | $project: { 23 | contest_id: 1, 24 | _id: 1, 25 | "rankings._id": 1, 26 | "rankings.delta": 1, 27 | "rankings.data_region": 1, 28 | }, 29 | }, 30 | { $match: { _id: contestId } }, 31 | { $unwind: "$rankings" }, 32 | { $match: { "rankings._id": { $in: handles } } }, 33 | ], 34 | function (err, result) { 35 | let resp = {}; 36 | if (err) { 37 | resp.status = "FAILED"; 38 | res.status(500).send(err); 39 | } else { 40 | resp.status = "OK"; 41 | resp.meta = { 42 | total_count: result.length, 43 | contest_id: contestId, 44 | }; 45 | resp.items = result.map((item) => item.rankings); 46 | res.send(resp); 47 | } 48 | } 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /services/predict-addon/build/Release/.deps/Release/obj.target/predict_addon/cpp/main.o.d: -------------------------------------------------------------------------------- 1 | cmd_Release/obj.target/predict_addon/cpp/main.o := g++ '-DNODE_GYP_MODULE_NAME=predict_addon' '-DUSING_UV_SHARED=1' '-DUSING_V8_SHARED=1' '-DV8_DEPRECATION_WARNINGS=1' '-DV8_DEPRECATION_WARNINGS' '-DV8_IMMINENT_DEPRECATION_WARNINGS' '-D_LARGEFILE_SOURCE' '-D_FILE_OFFSET_BITS=64' '-D__STDC_FORMAT_MACROS' '-DOPENSSL_NO_PINSHARED' '-DOPENSSL_THREADS' '-DBUILDING_NODE_EXTENSION' -I/home/sudesh/.cache/node-gyp/14.17.3/include/node -I/home/sudesh/.cache/node-gyp/14.17.3/src -I/home/sudesh/.cache/node-gyp/14.17.3/deps/openssl/config -I/home/sudesh/.cache/node-gyp/14.17.3/deps/openssl/openssl/include -I/home/sudesh/.cache/node-gyp/14.17.3/deps/uv/include -I/home/sudesh/.cache/node-gyp/14.17.3/deps/zlib -I/home/sudesh/.cache/node-gyp/14.17.3/deps/v8/include -fPIC -pthread -Wall -Wextra -Wno-unused-parameter -m64 -O3 -fno-omit-frame-pointer -fno-rtti -fno-exceptions -std=gnu++1y -MMD -MF ./Release/.deps/Release/obj.target/predict_addon/cpp/main.o.d.raw -c -o Release/obj.target/predict_addon/cpp/main.o ../cpp/main.cpp 2 | Release/obj.target/predict_addon/cpp/main.o: ../cpp/main.cpp \ 3 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/node.h \ 4 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8.h \ 5 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/cppgc/common.h \ 6 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8config.h \ 7 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8-internal.h \ 8 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8-version.h \ 9 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8config.h \ 10 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8-platform.h \ 11 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/node_version.h \ 12 | ../cpp/helpers.h ../cpp/predict.h 13 | ../cpp/main.cpp: 14 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/node.h: 15 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8.h: 16 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/cppgc/common.h: 17 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8config.h: 18 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8-internal.h: 19 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8-version.h: 20 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8config.h: 21 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/v8-platform.h: 22 | /home/sudesh/.cache/node-gyp/14.17.3/include/node/node_version.h: 23 | ../cpp/helpers.h: 24 | ../cpp/predict.h: 25 | -------------------------------------------------------------------------------- /tests/ratelimit.test.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | 3 | describe("Rate limit tests", () => { 4 | jest.setTimeout(20 * 1000); 5 | const itr = 10; 6 | 7 | test("Rate limit for web enpoints", async () => { 8 | const url = "http://127.0.0.1:8080/"; 9 | const limit = 50; 10 | let success = 0; 11 | for (let i = 0; i < itr; i++) { 12 | let promises = []; 13 | for (let j = 0; j < 100; j++) { 14 | promises.push( 15 | new Promise((resolve, reject) => { 16 | fetch(url) 17 | .then((res) => { 18 | resolve(res.status !== 429); 19 | }) 20 | .catch((err) => { 21 | console.error(err); 22 | reject(); 23 | }); 24 | }) 25 | ); 26 | } 27 | const resp = await Promise.all(promises); 28 | resp.forEach((res) => { 29 | if (res) { 30 | success++; 31 | } 32 | }); 33 | } 34 | expect(success).toBeLessThanOrEqual(limit); 35 | }); 36 | it("wait for new window", async () => { 37 | await new Promise((resolve) => setTimeout(resolve, 10 * 1000)); 38 | }); 39 | 40 | test("Rate limit for api enpoints", async () => { 41 | const url = "http://127.0.0.1:8080/api/v1/ping"; 42 | const limit = 20; 43 | let success = 0; 44 | for (let i = 0; i < itr; i++) { 45 | let promises = []; 46 | for (let j = 0; j < 100; j++) { 47 | promises.push( 48 | new Promise((resolve, reject) => { 49 | fetch(url) 50 | .then((res) => { 51 | resolve(res.status !== 429); 52 | }) 53 | .catch((err) => { 54 | console.error(err); 55 | reject(); 56 | }); 57 | }) 58 | ); 59 | } 60 | const resp = await Promise.all(promises); 61 | resp.forEach((res) => { 62 | if (res) { 63 | success++; 64 | } 65 | }); 66 | } 67 | expect(success).toBeLessThanOrEqual(limit); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /controllers/sitemapController.js: -------------------------------------------------------------------------------- 1 | const Contest = require("../models/contest"); 2 | const { SitemapStream, streamToPromise } = require("sitemap"); 3 | const { createGzip } = require("zlib"); 4 | 5 | const HOST = process.env.HOST || "https://lcpredictor.onrender.com/"; 6 | 7 | const getURLsCollection = async () => { 8 | const collection = []; 9 | const formatedDate = function (date) { 10 | let year = date.getFullYear(); 11 | let month = date.getMonth() + 1; 12 | let day = date.getDate(); 13 | if (month < 10) { 14 | month = "0" + month; 15 | } 16 | if (day < 10) { 17 | day = "0" + day; 18 | } 19 | 20 | return year + "-" + month + "-" + day; 21 | }; 22 | 23 | collection.push({ 24 | url: "/", 25 | lastmod: formatedDate(new Date(Date.now())), 26 | changefreq: "weekly", 27 | priority: 1, 28 | }); 29 | contests = await Contest.find({}, { rankings: 0 }); 30 | 31 | contests.forEach((contest) => { 32 | user_num = contest.user_num; 33 | if (user_num == undefined) { 34 | return; 35 | } 36 | totalPages = Math.ceil(user_num / 25); 37 | 38 | for (let i = 1; i <= totalPages; i++) { 39 | collection.push({ 40 | url: `/contest/${contest._id}/ranking/${i}`, 41 | lastmod: new Date(contest.lastUpdated), 42 | priority: i == 1 ? 0.9 : 0.8, 43 | }); 44 | } 45 | }); 46 | return collection; 47 | }; 48 | 49 | let sitemapXML; 50 | exports.get = async function (req, res) { 51 | res.header("Content-Encoding", "gzip"); 52 | res.set("Content-Type", "application/xml"); 53 | 54 | // if we have a cached entry send it 55 | if (sitemapXML) { 56 | res.send(sitemapXML); 57 | return; 58 | } 59 | try { 60 | console.log("Generating Sitemap..."); 61 | const smStream = new SitemapStream({ hostname: HOST }); 62 | const pipeline = smStream.pipe(createGzip()); 63 | const collection = await getURLsCollection(); 64 | collection.forEach((ele) => { 65 | smStream.write(ele); 66 | }); 67 | // cache the response 68 | streamToPromise(pipeline).then((sm) => (sitemapXML = sm)); 69 | 70 | smStream.end(); 71 | 72 | // stream write the response 73 | pipeline.pipe(res).on("error", (e) => { 74 | console.error(e); 75 | }); 76 | } catch (e) { 77 | console.error(e); 78 | res.status(500).end(); 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /views/layouts/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= title %> 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | 33 |
34 | 38 | <%- body %> 39 | 45 | 48 | 51 | <%- script %> 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /services/predict.js: -------------------------------------------------------------------------------- 1 | const { fetchContestRankings } = require("./contests"); 2 | const { getContestParticipantsData } = require("./users"); 3 | const Contest = require("../models/contest"); 4 | const predictAddon = require("./predict-addon"); 5 | const THREAD_CNT = Number(process.env.THREAD_CNT) || 4; 6 | 7 | const predict = async (job) => { 8 | try { 9 | console.time(`Predictions (${job.data.contestSlug})`); 10 | let contest, participantsData, err; 11 | console.log(`Fetching contest rankings (${job.data.contestSlug})...`); 12 | console.time(`fetchContestRankings(${job.data.contestSlug})`); 13 | [contest, err] = await fetchContestRankings(job.data.contestSlug); 14 | console.timeEnd(`fetchContestRankings(${job.data.contestSlug})`); 15 | if (err) { 16 | return err; 17 | } 18 | job.progress(30); 19 | 20 | console.log(`Fetching participants' data(${job.data.contestSlug})...`); 21 | console.time(`getContestParticipantsData(${job.data.contestSlug})`); 22 | participantsData = await getContestParticipantsData(contest); 23 | console.timeEnd(`getContestParticipantsData(${job.data.contestSlug})`); 24 | 25 | if (participantsData.length === 0) { 26 | return new Error( 27 | `Participants data not found (${job.data.contestSlug})` 28 | ); 29 | } 30 | 31 | job.progress(70); 32 | console.log(`Predicting ratings for ${job.data.contestSlug}...`); 33 | console.time(`Predict ratings(${job.data.contestSlug})`); 34 | const predictedRatings = predictAddon.predict( 35 | participantsData, 36 | THREAD_CNT 37 | ); 38 | console.timeEnd(`Predict ratings(${job.data.contestSlug})`); 39 | 40 | job.progress(85); 41 | 42 | console.log( 43 | `Updating db with predicted ratings (${job.data.contestSlug})...` 44 | ); 45 | console.time(`Update db(${job.data.contestSlug})`); 46 | for ( 47 | let i = 0; 48 | i < contest.rankings.length && i < predictedRatings.length; 49 | i++ 50 | ) { 51 | if (predictedRatings[i] != -1) { 52 | contest.rankings[i].current_rating = participantsData[i].rating; 53 | contest.rankings[i].delta = 54 | predictedRatings[i] - participantsData[i].rating; 55 | } 56 | } 57 | contest.lastUpdated = Date.now(); 58 | contest.ratings_predicted = true; 59 | job.progress(90); 60 | await contest.save(); 61 | job.progress(100); 62 | console.timeEnd(`Update db(${job.data.contestSlug})`); 63 | console.timeEnd(`Predictions (${job.data.contestSlug})`); 64 | } catch (err) { 65 | return err; 66 | } 67 | }; 68 | 69 | exports.predict = predict; 70 | -------------------------------------------------------------------------------- /services/job-queues/contestPredictionQueue.js: -------------------------------------------------------------------------------- 1 | const Queue = require("bull"); 2 | const Contest = require("../../models/contest"); 3 | const { predict } = require("../predict"); 4 | const opts = require("../redis"); 5 | const { updateUsers } = require("../users"); 6 | 7 | opts.lockDuration = 30 * 60 * 1000; // 30 minutes 8 | opts.maxStalledCount = 0; 9 | 10 | const predictQueue = new Queue("Predictions", opts); 11 | predictQueue.process("predictRatings", async (job, done) => { 12 | try { 13 | console.log(`Processing ${job.name} job: ${job.id}`); 14 | 15 | const contest = await Contest.findById(job.data.contestSlug, { 16 | ratings_predicted: 1, 17 | }); 18 | 19 | const refetch = job.data.refetch && job.attemptsMade === 0; // applicable for the first attempt only 20 | 21 | if (!refetch && contest && contest.ratings_predicted) { 22 | done(null, { message: "skipped (already predicted)" }); 23 | return; 24 | } 25 | 26 | if (refetch) { 27 | contest.refetch_rankings = true; 28 | await contest.save(); 29 | } 30 | 31 | const err = await predict(job); 32 | if (err) { 33 | console.error(err); 34 | done(err); 35 | } 36 | done(null, { message: "DONE" }); 37 | } catch (err) { 38 | done(err); 39 | } 40 | }); 41 | 42 | predictQueue.process("updateUserData", async (job, done) => { 43 | try { 44 | const predictRatingJobScheduled = await checkPredictRatingJobs(); 45 | if (predictRatingJobScheduled) { 46 | done( 47 | new Error( 48 | "PredictRating job is scheduled. Cannot process this job right now." 49 | ) 50 | ); 51 | return; 52 | } 53 | console.log("Processing job: ", job.id); 54 | const err = await updateUsers(job); 55 | if (err) { 56 | console.error(err); 57 | done(err); 58 | } else { 59 | done(); 60 | } 61 | } catch (err) { 62 | done(err); 63 | } 64 | }); 65 | 66 | // funtion to check if any predictRating job is scheduled 67 | const checkPredictRatingJobs = async () => { 68 | let jobs = await predictQueue.getJobs([ 69 | "active", 70 | "waiting", 71 | "delayed", 72 | "failed", 73 | ]); 74 | jobs = jobs.filter((job) => job.name === "predictRatings"); 75 | let date = new Date(); 76 | date.setDate(date.getDate() + 30); 77 | for (job of jobs) { 78 | date = Math.min(date, new Date(job.opts.timestamp + job.opts.delay)); 79 | } 80 | return date - Date.now() <= 1 * 60 * 60 * 1000; 81 | }; 82 | 83 | predictQueue.on("completed", (job, result) => { 84 | console.log(`${job.name} Job: ${job.id} completed.`); 85 | }); 86 | 87 | module.exports = predictQueue; 88 | -------------------------------------------------------------------------------- /services/predict-addon/cpp/helpers.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include "predict.h" 4 | using namespace v8; 5 | 6 | Rank unpackRank(Isolate* isolate,const Local rankObj){ 7 | Local context = isolate->GetCurrentContext(); 8 | Local rating = rankObj->Get(context,String::NewFromUtf8(isolate,"rating").ToLocalChecked()).ToLocalChecked(); 9 | Local isFirstContest = rankObj->Get(context,String::NewFromUtf8(isolate,"isFirstContest").ToLocalChecked()).ToLocalChecked(); 10 | if(!rating->IsNumber() || !isFirstContest->IsBoolean()){ 11 | isolate->ThrowException( 12 | Exception::TypeError( 13 | String::NewFromUtf8( 14 | isolate,"Wrong arguments. Array element properties do not have correct datatype." 15 | ).ToLocalChecked() 16 | ) 17 | ); 18 | } 19 | Rank rank = {rating->NumberValue(context).FromMaybe(-1),-1,isFirstContest->BooleanValue(isolate)}; 20 | return rank; 21 | } 22 | 23 | vector unpackRankList(Isolate* isolate,const FunctionCallbackInfo &args){ 24 | if(args.Length()==0 || !args[0]->IsArray()){ 25 | isolate->ThrowException( 26 | Exception::TypeError( 27 | String::NewFromUtf8( 28 | isolate,"Wrong arguments. First argument must be an array." 29 | ).ToLocalChecked() 30 | ) 31 | ); 32 | return {}; 33 | } 34 | Local context = isolate->GetCurrentContext(); 35 | vector rankList; 36 | Local array = Local::Cast(args[0]); 37 | for(int i=0;i<(int)array->Length();i++){ 38 | Local obj = Local::Cast(array->Get(context,i).ToLocalChecked()); 39 | if(!(obj->Has(context,String::NewFromUtf8(isolate,"rating").ToLocalChecked())).ToChecked() || !(obj->Has(context,String::NewFromUtf8(isolate,"isFirstContest").ToLocalChecked())).ToChecked()){ 40 | isolate->ThrowException( 41 | Exception::TypeError( 42 | String::NewFromUtf8( 43 | isolate,"Wrong arguments. Array element does not has all required properties." 44 | ).ToLocalChecked() 45 | ) 46 | ); 47 | return {}; 48 | } 49 | auto rank = unpackRank(isolate,obj); 50 | rankList.push_back(rank); 51 | } 52 | return rankList; 53 | } 54 | 55 | Local packRankList(vector &rankList){ 56 | Local context = v8::Isolate::GetCurrent()->GetCurrentContext(); 57 | Local array = Array::New(v8::Isolate::GetCurrent(),rankList.size()); 58 | for(int i=0;i<(int)rankList.size();i++){ 59 | Local predictedRating = Number::New(v8::Isolate::GetCurrent(),rankList[i].predictedRating); 60 | array->Set(context,i,predictedRating); 61 | } 62 | return array; 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /services/predict-addon/cpp/predict.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | using namespace std; 6 | 7 | struct Rank{ 8 | double currentRating; 9 | double predictedRating; 10 | bool isFirstContest; 11 | }; 12 | 13 | // reference: https://leetcode.com/discuss/general-discussion/468851/New-Contest-Rating-Algorithm-(Coming-Soon) 14 | 15 | class Predict{ 16 | 17 | public: 18 | 19 | Predict(vector &data,int THREAD_CNT){ 20 | int n = data.size(); 21 | if(n==0) 22 | return; 23 | auto calculate = [this](vector &data,int l,int r){ 24 | for(int i=l;i Expected Rank: "< threads; 43 | for(int i=0;i &data,double GMean){ 54 | double l = 1,r=1e6,mid,seed; 55 | while(r-l>0.1){ 56 | mid = l + (r-l)/2; 57 | seed = 1 + getExpectedRank(data,mid); 58 | if(seed > GMean){ 59 | l = mid; // to reduce seed -> increase ERating 60 | } else { 61 | r = mid; // to increase seed -> decrease ERating 62 | } 63 | } 64 | return mid; 65 | } 66 | 67 | double getExpectedRank(vector &data,double userRating){ 68 | // sum over all participants' probabilities to win 69 | double seed = 0; 70 | for(int i=0;i<(int)data.size();i++){ 71 | if(data[i].currentRating !=-1){ 72 | seed += meanWinningPercentage(data[i].currentRating,userRating); 73 | } 74 | } 75 | return seed; 76 | } 77 | 78 | double meanWinningPercentage(double ratingA,double ratingB){ 79 | return 1/(1+pow(10,(ratingB-ratingA)/400)); 80 | } 81 | 82 | double geometricMean(double eRank,double rank){ 83 | return sqrt(eRank*rank); 84 | } 85 | }; -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | if (process.env.NODE_ENV !== "production") { 2 | require("dotenv").config(); 3 | } 4 | const express = require("express"); 5 | const rateLimit = require("express-rate-limit"); 6 | const unless = require("express-unless"); 7 | 8 | const expressLayouts = require("express-ejs-layouts"); 9 | expressLayouts.unless = unless; 10 | 11 | const bodyParser = require("body-parser"); 12 | 13 | // database 14 | const mongoose = require("mongoose"); 15 | mongoose.connect(process.env.DATABASE_URL, { 16 | useNewUrlParser: true, 17 | useUnifiedTopology: true, 18 | }); 19 | mongoose.set("useFindAndModify", false); 20 | const db = mongoose.connection; 21 | db.on("error", (error) => console.error(error)); 22 | db.once("open", () => { 23 | console.info("Connected to Mongoose"); 24 | }); 25 | 26 | const app = express(); 27 | 28 | const limiter = rateLimit({ 29 | max: process.env.RATE_LIMIT || 50, 30 | windowMs: process.env.RATE_LIMIT_WINDOW || 10 * 1000, 31 | message: "Too many requests, please try again later.", 32 | }); 33 | app.enable("trust proxy"); 34 | app.use(limiter); 35 | 36 | // body limit 37 | app.use(express.json({ limit: "10kb" })); 38 | 39 | app.set("view engine", "ejs"); 40 | app.set("views", __dirname + "/views"); 41 | app.use(bodyParser.urlencoded({ extended: true })); 42 | app.use(express.static("public")); 43 | app.use( 44 | expressLayouts.unless({ 45 | path: [/\/bull-board*/], 46 | }) 47 | ); 48 | app.set("layout", "layouts/layout"); 49 | app.set("layout extractScripts", true); 50 | 51 | // background 52 | if (process.env.BACKGROUND == true) { 53 | const { bullBoardServerAdapter } = require("./background"); 54 | const { ensureLoggedIn } = require("connect-ensure-login"); 55 | const passport = require("passport"); 56 | const session = require("express-session"); 57 | app.use(session({ secret: process.env.SESSION_SECRET })); 58 | app.use(passport.initialize({})); 59 | app.use(passport.session({})); 60 | const authRouter = require("./routes/auth"); 61 | app.use("/login", authRouter); 62 | app.use( 63 | "/bull-board", 64 | ensureLoggedIn("/login"), 65 | bullBoardServerAdapter.getRouter() 66 | ); 67 | console.info("BACKGROUND is up."); 68 | } 69 | 70 | // web 71 | if (process.env.WEB == true) { 72 | const webRouter = require("./web"); 73 | app.use("/", webRouter); 74 | console.info("WEB is up."); 75 | } 76 | 77 | // api 78 | if (!process.env.API_DISABLED) { 79 | const apiLimiter = rateLimit({ 80 | max: process.env.API_RATE_LIMIT || 20, 81 | windowMs: process.env.API_RATE_LIMIT_WINDOW || 10 * 1000, 82 | message: "Too many requests, please try again later.", 83 | // keyGenerator: function (req) { 84 | // return req.ip; 85 | // }, 86 | }); 87 | app.use("/api/", apiLimiter); 88 | const apiRoutes = require("./routes/api"); 89 | app.use("/api/v1/", apiRoutes); 90 | console.info("API is up."); 91 | } 92 | 93 | // 404 page 94 | app.use((req, res) => { 95 | res.status(404).render("errors/404", { 96 | title: "404 Not Found", 97 | }); 98 | }); 99 | 100 | const port = process.env.PORT || 8080; 101 | 102 | app.listen(port, (err) => { 103 | if (err) { 104 | console.error(err); 105 | return; 106 | } 107 | console.info("Listening on " + port); 108 | }); 109 | -------------------------------------------------------------------------------- /services/job-queues/jobScheduler.js: -------------------------------------------------------------------------------- 1 | const Queue = require("bull"); 2 | const Contest = require("../../models/contest"); 3 | const { User } = require("../../models/user"); 4 | const contestPredictionQueue = require("./contestPredictionQueue"); 5 | const opts = require("../redis"); 6 | const { fetchContestsMetaData } = require("../contests"); 7 | const { 8 | convertDateYYYYMMDD, 9 | IsLatestContest, 10 | getRemainingTime, 11 | } = require("../../helpers"); 12 | const scheduler = new Queue("Scheduler", opts); 13 | 14 | scheduler.process("contestScheduler", async (job, done) => { 15 | console.log(`Processing ${job.name} job (Id: ${job.id})...`); 16 | await fetchContestsMetaData(); 17 | const contests = await Contest.find({}, { rankings: 0 }); 18 | let cnt = 0; 19 | 20 | contests.forEach((contest) => { 21 | let remainingTime = getRemainingTime(contest.endTime); 22 | if (remainingTime > 0) { 23 | remainingTime += 5 * 60 * 1000; // 5 minutes delay for upcoming contests 24 | } 25 | if (!contest.ratings_predicted && IsLatestContest(contest.endTime)) { 26 | contestPredictionQueue.add( 27 | "predictRatings", 28 | { 29 | contestSlug: contest._id, 30 | }, 31 | { 32 | jobId: contest._id, 33 | attempts: 5, 34 | delay: remainingTime, 35 | backoff: 10000, 36 | priority: 1, 37 | } 38 | ); 39 | cnt++; 40 | // refetch for the upcoming contests 41 | if (remainingTime > 0) { 42 | remainingTime += 55 * 60 * 1000; // 1 hour after the contest 43 | contestPredictionQueue.add( 44 | "predictRatings", 45 | { 46 | contestSlug: contest._id, 47 | refetch: true, 48 | }, 49 | { 50 | jobId: "refetch_" + contest._id, 51 | attempts: 5, 52 | delay: remainingTime, 53 | backoff: 10000, 54 | priority: 1, 55 | } 56 | ); 57 | cnt++; 58 | } 59 | } 60 | }); 61 | 62 | job.progress(100); 63 | done(null, cnt); 64 | }); 65 | 66 | scheduler.process("updateUserDataScheduler", async (job, done) => { 67 | console.log(`Processing ${job.name} job (Id: ${job.id})...`); 68 | const totalUsers = await User.estimatedDocumentCount({}); 69 | const { rateLimit, limit } = job.data; 70 | let cnt = 0; 71 | console.log("Total users:", totalUsers); 72 | 73 | for (let i = 0; i < totalUsers; i += limit) { 74 | const date = new Date(); 75 | const hoursWindow = Math.floor(date.getHours() / 4); 76 | const jobId = `updateUsers|${i}-${ 77 | i + limit 78 | }|${hoursWindow}|${convertDateYYYYMMDD(date)}`; 79 | 80 | contestPredictionQueue.add( 81 | "updateUserData", 82 | { 83 | limit, 84 | offset: i, 85 | rateLimit, 86 | }, 87 | { 88 | jobId, 89 | attempts: 5, 90 | backoff: 10000, 91 | } 92 | ); 93 | cnt++; 94 | } 95 | job.progress(100); 96 | done(null, cnt); 97 | }); 98 | 99 | scheduler.on("completed", function (job, result) { 100 | console.log( 101 | `${job.name} job (job id: ${job.id}) completed! Total scheduled jobs: ${result}` 102 | ); 103 | }); 104 | 105 | module.exports = scheduler; 106 | -------------------------------------------------------------------------------- /chrome-extension/background.js: -------------------------------------------------------------------------------- 1 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 2 | if ( 3 | changeInfo.status === "complete" && 4 | /^https:\/\/leetcode.com\/contest\/.+/.test(tab.url) 5 | ) { 6 | chrome.scripting 7 | .executeScript({ 8 | target: { 9 | tabId: tabId, 10 | }, 11 | files: ["./foreground.js"], 12 | }) 13 | .then(() => { 14 | console.log( 15 | `Injected the foreground script into tab: ${tabId}` 16 | ); 17 | chrome.tabs.sendMessage(tabId, { 18 | message: "url_updated", 19 | url: tab.url, 20 | }); 21 | }) 22 | .catch((err) => console.error(err)); 23 | } 24 | }); 25 | 26 | const API_URLS = [ 27 | "https://lcpredictor.onrender.com/api/v1/predictions" 28 | ]; 29 | 30 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 31 | if (request.message === "get_predictions") { 32 | getPredictions(request.data) 33 | .then((res) => { 34 | // console.log(res); 35 | sendResponse(res); 36 | }) 37 | .catch((err) => { 38 | console.error(err); 39 | }); 40 | return true; 41 | } 42 | }); 43 | 44 | async function getPredictions(data) { 45 | try { 46 | let urlIndex = await getURL_INDEX(); 47 | if (urlIndex === undefined || urlIndex < 0 || urlIndex >= API_URLS) { 48 | urlIndex = 0; 49 | } 50 | for (let i = 0; i < API_URLS.length; i++) { 51 | try { 52 | const ind = (i + urlIndex) % API_URLS.length; 53 | const url = new URL(API_URLS[ind]); 54 | 55 | url.searchParams.set("contestId", data.contestId); 56 | let handles = ""; 57 | data.handles.forEach((handle, index) => { 58 | handles += 59 | handle + (index !== data.handles.length - 1 ? ";" : ""); 60 | }); 61 | url.searchParams.set("handles", handles); 62 | 63 | const resp = await fetchFromAPI(url); 64 | setURL_INDEX(ind).catch((err) => { 65 | console.error(err); 66 | }); 67 | return resp; 68 | } catch (err) { 69 | console.error(err); 70 | } 71 | } 72 | } catch (err) { 73 | console.error(err); 74 | } 75 | } 76 | 77 | async function fetchFromAPI(url, retries = 5) { 78 | let resp = await fetch(url); 79 | if (resp.status !== 200) { 80 | if (retries > 0) { 81 | resp = await fetchFromAPI(url, retries - 1); 82 | return resp; 83 | } 84 | throw new Error(resp.statusText); 85 | } 86 | resp = await resp.json(); 87 | return resp; 88 | } 89 | 90 | async function setURL_INDEX(index) { 91 | try { 92 | const promise = new Promise((resolve, reject) => { 93 | chrome.storage.sync.set( 94 | { 95 | url_index: index, 96 | }, 97 | function () { 98 | resolve(); 99 | } 100 | ); 101 | }); 102 | await promise; 103 | } catch (err) { 104 | return err; 105 | } 106 | } 107 | 108 | async function getURL_INDEX() { 109 | try { 110 | const promise = new Promise((resolve, reject) => { 111 | chrome.storage.sync.get(["url_index"], function (result) { 112 | resolve(result.url_index); 113 | }); 114 | }); 115 | const index = await promise; 116 | return index; 117 | } catch (err) { 118 | console.error(err); 119 | return -1; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /services/predict-addon/test/test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const filePath = "./test/weekly-contest-242.json"; 3 | const data = JSON.parse(fs.readFileSync(filePath)); 4 | 5 | const addon = require("../build/Release/predict_addon"); 6 | 7 | let predictedRatings = []; 8 | console.time("rating predictions (C++)"); 9 | predictedRatings = addon.predict(data, 4); 10 | console.timeEnd("rating predictions (C++)"); 11 | 12 | // calculates mean square error 13 | const MSE = (predictedRatings) => { 14 | let mse = 0.0, 15 | total = data.length; 16 | data.forEach((ele, index) => { 17 | if (ele.rating !== -1) { 18 | mse += Math.pow(ele.actualRating - predictedRatings[index], 2); 19 | } else { 20 | total--; 21 | } 22 | }); 23 | mse /= total; 24 | console.log("MSE: ", mse); 25 | }; 26 | 27 | MSE(predictedRatings); 28 | 29 | // predicts ratings for the given data (Js implementation) 30 | const predictJs = (data) => { 31 | const getRating = (GMean) => { 32 | let l = 1, 33 | r = 100000, 34 | mid, 35 | seed; 36 | while (r - l > 0.1) { 37 | mid = l + (r - l) / 2; 38 | seed = 1 + getExpectedRank(mid); 39 | if (seed > GMean) { 40 | l = mid; 41 | } else { 42 | r = mid; 43 | } 44 | } 45 | return mid; 46 | }; 47 | 48 | const getExpectedRank = (userRating) => { 49 | let seed = 0; 50 | for (let i = 0; i < data.length; i++) { 51 | if (data[i].rating !== -1) { 52 | seed += meanWinningPercentage(data[i].rating, userRating); 53 | } 54 | } 55 | return seed; 56 | }; 57 | 58 | const meanWinningPercentage = (ratingA, ratingB) => { 59 | return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); 60 | }; 61 | 62 | const geometricMean = (eRank, rank) => { 63 | return Math.sqrt(eRank * rank); 64 | }; 65 | 66 | let result = new Array(data.length); 67 | const calculate = (i) => { 68 | if (data[i].rating === -1) return; 69 | const expectedRank = 0.5 + getExpectedRank(data[i].rating); 70 | const GMean = geometricMean(expectedRank, i + 1); 71 | const expectedRating = getRating(GMean); 72 | let delta = expectedRating - data[i].rating; 73 | if (data[i].isFirstContest) delta *= 0.5; 74 | else delta = (delta * 2) / 9; 75 | result[i] = data[i].rating + delta; 76 | // console.log( 77 | // i + 1, 78 | // "=> Expected Rank: ", 79 | // expectedRank, 80 | // " GMean: ", 81 | // GMean, 82 | // " expectedRating: ", 83 | // expectedRating, 84 | // " Delta: ", 85 | // delta, 86 | // " New rating: ", 87 | // result[i] 88 | // ); 89 | }; 90 | 91 | for (let i = 0; i < data.length; i++) { 92 | calculate(i); 93 | } 94 | return result; 95 | }; 96 | 97 | console.time("rating predictions (Js)"); 98 | predictedRatings = predictJs(data); 99 | console.timeEnd("rating predictions (Js)"); 100 | MSE(predictedRatings); 101 | 102 | // Js time: 200.617 sec 103 | 104 | // function to calculate the r squared 105 | const calculateRSquared = () => { 106 | let r = 0.0, 107 | sigmaXY = 0.0, 108 | sigmaX = 0.0, 109 | sigmaY = 0.0, 110 | sigmaXX = 0.0, 111 | sigmaYY = 0.0; 112 | 113 | data.forEach((ele, index) => { 114 | if (ele.rating !== -1) { 115 | sigmaXY += ele.actualRating * predictedRatings[index]; 116 | sigmaX += ele.actualRating; 117 | sigmaY += predictedRatings[index]; 118 | sigmaXX += ele.actualRating * ele.actualRating; 119 | sigmaYY += predictedRatings[index] * predictedRatings[index]; 120 | } 121 | }); 122 | let n = data.length; 123 | r = 124 | (n * sigmaXY - sigmaX * sigmaY) / 125 | Math.sqrt( 126 | (n * sigmaXX - sigmaX * sigmaX) * (n * sigmaYY - sigmaY * sigmaY) 127 | ); 128 | console.log("r-squared: ", Math.pow(r, 2)); 129 | }; 130 | 131 | calculateRSquared(); 132 | -------------------------------------------------------------------------------- /public/stylesheets/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | color: rgba(0, 0, 0, 0.65); 4 | font-size: 14px; 5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", 6 | "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, 7 | Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 8 | "Segoe UI Symbol"; 9 | font-variant: tabular-nums; 10 | line-height: 1.5; 11 | background-color: #fff; 12 | -webkit-font-feature-settings: "tnum"; 13 | font-feature-settings: "tnum"; 14 | } 15 | 16 | #forkMe { 17 | top: 3em; 18 | right: -6em; 19 | color: #fff; 20 | display: block; 21 | position: fixed; 22 | text-align: center; 23 | text-decoration: none; 24 | letter-spacing: 0.06em; 25 | background-color: #337ab7; 26 | padding: 0.5em 5em 0.4em 5em; 27 | text-shadow: 0 0 0.75em #444; 28 | box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5); 29 | transform: rotate(45deg) scale(0.75, 1); 30 | font: bold 16px/1.2em Arial, Sans-Serif; 31 | -webkit-text-shadow: 0 0 0.75em #444; 32 | -webkit-box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.5); 33 | -webkit-transform: rotate(45deg) scale(0.75, 1); 34 | z-index: 10; 35 | } 36 | 37 | #forkMe:before { 38 | content: ""; 39 | top: 0; 40 | left: 0; 41 | right: 0; 42 | bottom: 0; 43 | position: absolute; 44 | margin: -0.3em -5em; 45 | transform: scale(0.7); 46 | -webkit-transform: scale(0.7); 47 | border: 2px rgba(255, 255, 255, 0.7) dashed; 48 | } 49 | 50 | @media only screen and (max-width: 600px) { 51 | #forkMe { 52 | font-size: small; 53 | } 54 | } 55 | 56 | #forkMe:hover { 57 | opacity: 0.9; 58 | } 59 | 60 | a { 61 | color: #1890ff; 62 | text-decoration: none; 63 | background-color: transparent; 64 | outline: none; 65 | cursor: pointer; 66 | -webkit-transition: color 0.3s; 67 | -o-transition: color 0.3s; 68 | transition: color 0.3s; 69 | -webkit-text-decoration-skip: objects; 70 | } 71 | 72 | .btn-primary { 73 | background-color: #337ab7 !important; 74 | border-color: #337ab7 !important; 75 | color: white !important; 76 | } 77 | 78 | .bg-primary { 79 | background-color: #337ab7 !important; 80 | color: white !important; 81 | } 82 | 83 | .btn-primary:focus, 84 | .btn-primary:active { 85 | background-color: #337ab7 !important; 86 | border-color: #337ab7 !important; 87 | box-shadow: 0 0 0 0.25rem #e9edf1 !important; 88 | } 89 | 90 | th, 91 | td { 92 | font-size: medium; 93 | } 94 | /* FAQ */ 95 | 96 | :root { 97 | --color-text: hsl(207, 10%, 40%); 98 | --color-text-muted: hsl(213, 10%, 40%); 99 | --color-skyblue: rgb(90, 149, 205); 100 | } 101 | 102 | .accordion { 103 | max-width: 42rem; 104 | padding: 1.2rem 0; 105 | border-radius: 1rem; 106 | background: white; 107 | /* box-shadow: 0 0 5rem lightgrey; */ 108 | } 109 | 110 | .accordion__heading { 111 | margin-bottom: 1rem; 112 | padding: 0 1.4rem; 113 | } 114 | 115 | .accordion__item:not(:last-child) { 116 | border-bottom: 1px solid lightgrey; 117 | } 118 | 119 | .accordion__btn { 120 | display: flex; 121 | justify-content: space-between; 122 | align-items: center; 123 | width: 100%; 124 | padding: 1.2rem 1.4rem; 125 | background: white; 126 | border: none; 127 | outline: none; 128 | color: var(--color-text); 129 | font-size: medium; 130 | text-align: left; 131 | cursor: pointer; 132 | transition: 0.1s; 133 | } 134 | .accordion__btn:hover { 135 | color: var(--color-skyblue); 136 | background: hsl(248, 53%, 97%); 137 | } 138 | 139 | .accordion__item--active .accordion__btn { 140 | color: var(--color-skyblue); 141 | border-bottom: 2px solid var(--color-skyblue); 142 | background: hsl(248, 53%, 97%); 143 | } 144 | 145 | .fa-lightbulb { 146 | padding-right: 1rem; 147 | } 148 | .accordion__icon { 149 | border-radius: 50%; 150 | transform: rotate(0deg); 151 | transition: 0.3s ease-in-out; 152 | opacity: 0.9; 153 | } 154 | .accordion__item--active .accordion__icon { 155 | transform: rotate(135deg); 156 | } 157 | 158 | .accordion__content { 159 | font-weight: 300; 160 | max-height: 0; 161 | opacity: 0; 162 | overflow: hidden; 163 | color: var(--color-text-muted); 164 | transform: translateX(16px); 165 | transition: max-height 0.5s ease, opacity 0.5s, transform 0.5s; 166 | } 167 | .accordion__content p { 168 | padding: 1rem 1.8rem; 169 | } 170 | 171 | .accordion__item--active .accordion__content { 172 | opacity: 1; 173 | transform: translateX(0px); 174 | max-height: 100vh; 175 | } 176 | -------------------------------------------------------------------------------- /controllers/rankingsController.js: -------------------------------------------------------------------------------- 1 | const { isNumeric,generatePagination } = require("../helpers"); 2 | const Contest = require("../models/contest"); 3 | 4 | exports.get = async function (req, res) { 5 | try { 6 | let entries = 25; 7 | let { contestSlug, page } = req.params; 8 | const country = req.query.country || "ALL"; 9 | const firstPage = req.query.firstPage; 10 | 11 | if (firstPage || page == null) { 12 | page = "1"; 13 | } 14 | if (!isNumeric(page)) { 15 | res.sendStatus(404); 16 | return; 17 | } 18 | page = parseInt(page); 19 | if (page < 1) { 20 | res.sendStatus(404); 21 | return; 22 | } 23 | let toSkip = (page - 1) * entries; 24 | let contest; 25 | if(country!=="ALL"){ 26 | contest = await Contest.aggregate( 27 | [ 28 | { 29 | $project: { 30 | rankings_fetched:1, 31 | user_num:1, 32 | _id: 1, 33 | title: 1, 34 | rankings:1, 35 | }, 36 | }, 37 | { $match: { _id: contestSlug } }, 38 | {$addFields:{ 39 | "rankings":{ 40 | $filter:{ 41 | input:"$rankings", 42 | as:"ranking", 43 | cond:{$eq:["$$ranking.country_code",country]} 44 | } 45 | }, 46 | 47 | }}, 48 | {$addFields:{ 49 | "user_num": {$size:"$rankings"}, 50 | }}, 51 | {$addFields:{ 52 | "rankings":{ 53 | $slice:["$rankings",toSkip,entries], 54 | } 55 | }} 56 | ]); 57 | if(contest && contest.length!==0){ 58 | contest = contest[0] 59 | } 60 | else{ 61 | contest = NULL; 62 | } 63 | } 64 | else{ 65 | contest = await Contest.findOne( 66 | { _id: contestSlug }, 67 | { 68 | rankings: { $slice: [toSkip, entries] }, 69 | title: 1, 70 | user_num: 1, 71 | rankings_fetched: 1, 72 | } 73 | ); 74 | } 75 | 76 | if (!contest || !contest.rankings_fetched) { 77 | res.sendStatus(404); 78 | return; 79 | } 80 | const totalPages = Math.ceil(contest.user_num / 25); 81 | if (totalPages>0 && page > totalPages) { 82 | res.sendStatus(404); 83 | return; 84 | } 85 | const pages = generatePagination(totalPages,page); 86 | const params = new URLSearchParams({ 87 | country, 88 | }); 89 | res.render("ranking", { 90 | contest, 91 | totalPages, 92 | page, 93 | pages, 94 | searchResult: false, 95 | title: `${contest.title} | Leetcode Rating Predictor`, 96 | params:params.toString() 97 | }); 98 | 99 | } catch (err) { 100 | console.error(err); 101 | res.sendStatus(500); 102 | } 103 | }; 104 | 105 | exports.search = async function (req, res) { 106 | try { 107 | let { user } = req.body; 108 | user = user.trim(); 109 | const result = await Contest.aggregate([ 110 | { 111 | $project: { 112 | contest_id: 1, 113 | _id: 1, 114 | rankings: 1, 115 | }, 116 | }, 117 | { $match: { _id: req.params.contestSlug } }, 118 | { $unwind: "$rankings" }, 119 | { $match: { "rankings._id": user } }, 120 | ]); 121 | const searchResult = result.map((item) => item.rankings); 122 | res.render("ranking", { 123 | contest: { 124 | _id: req.params.contestSlug, 125 | rankings: searchResult, 126 | }, 127 | page: 1, 128 | totalPages: 1, 129 | searchResult: true, 130 | title: `Search | ${req.params.contestSlug} | Leetcode Rating Predictor`, 131 | }); 132 | } catch (err) { 133 | console.error(err); 134 | res.sendStatus(500); 135 | } 136 | }; 137 | -------------------------------------------------------------------------------- /services/predict-addon/build/predict_addon.target.mk: -------------------------------------------------------------------------------- 1 | # This file is generated by gyp; do not edit. 2 | 3 | TOOLSET := target 4 | TARGET := predict_addon 5 | DEFS_Debug := \ 6 | '-DNODE_GYP_MODULE_NAME=predict_addon' \ 7 | '-DUSING_UV_SHARED=1' \ 8 | '-DUSING_V8_SHARED=1' \ 9 | '-DV8_DEPRECATION_WARNINGS=1' \ 10 | '-DV8_DEPRECATION_WARNINGS' \ 11 | '-DV8_IMMINENT_DEPRECATION_WARNINGS' \ 12 | '-D_LARGEFILE_SOURCE' \ 13 | '-D_FILE_OFFSET_BITS=64' \ 14 | '-D__STDC_FORMAT_MACROS' \ 15 | '-DOPENSSL_NO_PINSHARED' \ 16 | '-DOPENSSL_THREADS' \ 17 | '-DBUILDING_NODE_EXTENSION' \ 18 | '-DDEBUG' \ 19 | '-D_DEBUG' \ 20 | '-DV8_ENABLE_CHECKS' 21 | 22 | # Flags passed to all source files. 23 | CFLAGS_Debug := \ 24 | -fPIC \ 25 | -pthread \ 26 | -Wall \ 27 | -Wextra \ 28 | -Wno-unused-parameter \ 29 | -m64 \ 30 | -g \ 31 | -O0 32 | 33 | # Flags passed to only C files. 34 | CFLAGS_C_Debug := 35 | 36 | # Flags passed to only C++ files. 37 | CFLAGS_CC_Debug := \ 38 | -fno-rtti \ 39 | -fno-exceptions \ 40 | -std=gnu++1y 41 | 42 | INCS_Debug := \ 43 | -I/home/sudesh/.cache/node-gyp/14.17.3/include/node \ 44 | -I/home/sudesh/.cache/node-gyp/14.17.3/src \ 45 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/openssl/config \ 46 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/openssl/openssl/include \ 47 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/uv/include \ 48 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/zlib \ 49 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/v8/include 50 | 51 | DEFS_Release := \ 52 | '-DNODE_GYP_MODULE_NAME=predict_addon' \ 53 | '-DUSING_UV_SHARED=1' \ 54 | '-DUSING_V8_SHARED=1' \ 55 | '-DV8_DEPRECATION_WARNINGS=1' \ 56 | '-DV8_DEPRECATION_WARNINGS' \ 57 | '-DV8_IMMINENT_DEPRECATION_WARNINGS' \ 58 | '-D_LARGEFILE_SOURCE' \ 59 | '-D_FILE_OFFSET_BITS=64' \ 60 | '-D__STDC_FORMAT_MACROS' \ 61 | '-DOPENSSL_NO_PINSHARED' \ 62 | '-DOPENSSL_THREADS' \ 63 | '-DBUILDING_NODE_EXTENSION' 64 | 65 | # Flags passed to all source files. 66 | CFLAGS_Release := \ 67 | -fPIC \ 68 | -pthread \ 69 | -Wall \ 70 | -Wextra \ 71 | -Wno-unused-parameter \ 72 | -m64 \ 73 | -O3 \ 74 | -fno-omit-frame-pointer 75 | 76 | # Flags passed to only C files. 77 | CFLAGS_C_Release := 78 | 79 | # Flags passed to only C++ files. 80 | CFLAGS_CC_Release := \ 81 | -fno-rtti \ 82 | -fno-exceptions \ 83 | -std=gnu++1y 84 | 85 | INCS_Release := \ 86 | -I/home/sudesh/.cache/node-gyp/14.17.3/include/node \ 87 | -I/home/sudesh/.cache/node-gyp/14.17.3/src \ 88 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/openssl/config \ 89 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/openssl/openssl/include \ 90 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/uv/include \ 91 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/zlib \ 92 | -I/home/sudesh/.cache/node-gyp/14.17.3/deps/v8/include 93 | 94 | OBJS := \ 95 | $(obj).target/$(TARGET)/cpp/main.o 96 | 97 | # Add to the list of files we specially track dependencies for. 98 | all_deps += $(OBJS) 99 | 100 | # CFLAGS et al overrides must be target-local. 101 | # See "Target-specific Variable Values" in the GNU Make manual. 102 | $(OBJS): TOOLSET := $(TOOLSET) 103 | $(OBJS): GYP_CFLAGS := $(DEFS_$(BUILDTYPE)) $(INCS_$(BUILDTYPE)) $(CFLAGS_$(BUILDTYPE)) $(CFLAGS_C_$(BUILDTYPE)) 104 | $(OBJS): GYP_CXXFLAGS := $(DEFS_$(BUILDTYPE)) $(INCS_$(BUILDTYPE)) $(CFLAGS_$(BUILDTYPE)) $(CFLAGS_CC_$(BUILDTYPE)) 105 | 106 | # Suffix rules, putting all outputs into $(obj). 107 | 108 | $(obj).$(TOOLSET)/$(TARGET)/%.o: $(srcdir)/%.cpp FORCE_DO_CMD 109 | @$(call do_cmd,cxx,1) 110 | 111 | # Try building from generated source, too. 112 | 113 | $(obj).$(TOOLSET)/$(TARGET)/%.o: $(obj).$(TOOLSET)/%.cpp FORCE_DO_CMD 114 | @$(call do_cmd,cxx,1) 115 | 116 | $(obj).$(TOOLSET)/$(TARGET)/%.o: $(obj)/%.cpp FORCE_DO_CMD 117 | @$(call do_cmd,cxx,1) 118 | 119 | # End of this set of suffix rules 120 | ### Rules for final target. 121 | LDFLAGS_Debug := \ 122 | -pthread \ 123 | -rdynamic \ 124 | -m64 125 | 126 | LDFLAGS_Release := \ 127 | -pthread \ 128 | -rdynamic \ 129 | -m64 130 | 131 | LIBS := 132 | 133 | $(obj).target/predict_addon.node: GYP_LDFLAGS := $(LDFLAGS_$(BUILDTYPE)) 134 | $(obj).target/predict_addon.node: LIBS := $(LIBS) 135 | $(obj).target/predict_addon.node: TOOLSET := $(TOOLSET) 136 | $(obj).target/predict_addon.node: $(OBJS) FORCE_DO_CMD 137 | $(call do_cmd,solink_module) 138 | 139 | all_deps += $(obj).target/predict_addon.node 140 | # Add target alias 141 | .PHONY: predict_addon 142 | predict_addon: $(builddir)/predict_addon.node 143 | 144 | # Copy this to the executable output path. 145 | $(builddir)/predict_addon.node: TOOLSET := $(TOOLSET) 146 | $(builddir)/predict_addon.node: $(obj).target/predict_addon.node FORCE_DO_CMD 147 | $(call do_cmd,copy) 148 | 149 | all_deps += $(builddir)/predict_addon.node 150 | # Short alias for building this executable. 151 | .PHONY: predict_addon.node 152 | predict_addon.node: $(obj).target/predict_addon.node $(builddir)/predict_addon.node 153 | 154 | # Add executable to "all" target. 155 | .PHONY: all 156 | all: $(builddir)/predict_addon.node 157 | 158 | -------------------------------------------------------------------------------- /services/predict-addon/build/config.gypi: -------------------------------------------------------------------------------- 1 | # Do not edit. File was generated by node-gyp's "configure" step 2 | { 3 | "target_defaults": { 4 | "cflags": [], 5 | "default_configuration": "Release", 6 | "defines": [], 7 | "include_dirs": [], 8 | "libraries": [] 9 | }, 10 | "variables": { 11 | "asan": 0, 12 | "build_v8_with_gn": "false", 13 | "coverage": "false", 14 | "dcheck_always_on": 0, 15 | "debug_nghttp2": "false", 16 | "debug_node": "false", 17 | "enable_lto": "false", 18 | "enable_pgo_generate": "false", 19 | "enable_pgo_use": "false", 20 | "error_on_warn": "false", 21 | "force_dynamic_crt": 0, 22 | "gas_version": "2.30", 23 | "host_arch": "x64", 24 | "icu_data_in": "../../deps/icu-tmp/icudt69l.dat", 25 | "icu_endianness": "l", 26 | "icu_gyp_path": "tools/icu/icu-generic.gyp", 27 | "icu_path": "deps/icu-small", 28 | "icu_small": "false", 29 | "icu_ver_major": "69", 30 | "is_debug": 0, 31 | "llvm_version": "0.0", 32 | "napi_build_version": "8", 33 | "node_byteorder": "little", 34 | "node_debug_lib": "false", 35 | "node_enable_d8": "false", 36 | "node_install_npm": "true", 37 | "node_module_version": 83, 38 | "node_no_browser_globals": "false", 39 | "node_prefix": "/", 40 | "node_release_urlbase": "https://nodejs.org/download/release/", 41 | "node_section_ordering_info": "", 42 | "node_shared": "false", 43 | "node_shared_brotli": "false", 44 | "node_shared_cares": "false", 45 | "node_shared_http_parser": "false", 46 | "node_shared_libuv": "false", 47 | "node_shared_nghttp2": "false", 48 | "node_shared_openssl": "false", 49 | "node_shared_zlib": "false", 50 | "node_tag": "", 51 | "node_target_type": "executable", 52 | "node_use_bundled_v8": "true", 53 | "node_use_dtrace": "false", 54 | "node_use_etw": "false", 55 | "node_use_node_code_cache": "true", 56 | "node_use_node_snapshot": "true", 57 | "node_use_openssl": "true", 58 | "node_use_v8_platform": "true", 59 | "node_with_ltcg": "false", 60 | "node_without_node_options": "false", 61 | "openssl_fips": "", 62 | "openssl_is_fips": "false", 63 | "ossfuzz": "false", 64 | "shlib_suffix": "so.83", 65 | "target_arch": "x64", 66 | "v8_enable_31bit_smis_on_64bit_arch": 0, 67 | "v8_enable_gdbjit": 0, 68 | "v8_enable_i18n_support": 1, 69 | "v8_enable_inspector": 1, 70 | "v8_enable_lite_mode": 0, 71 | "v8_enable_object_print": 1, 72 | "v8_enable_pointer_compression": 0, 73 | "v8_no_strict_aliasing": 1, 74 | "v8_optimized_debug": 1, 75 | "v8_promise_internal_field_count": 1, 76 | "v8_random_seed": 0, 77 | "v8_trace_maps": 0, 78 | "v8_use_siphash": 1, 79 | "want_separate_host_toolset": 0, 80 | "nodedir": "/home/sudesh/.cache/node-gyp/14.17.3", 81 | "standalone_static_library": 1, 82 | "cache_lock_stale": "60000", 83 | "ham_it_up": "", 84 | "legacy_bundling": "", 85 | "sign_git_tag": "", 86 | "user_agent": "npm/6.14.13 node/v14.17.3 linux x64", 87 | "always_auth": "", 88 | "bin_links": "true", 89 | "key": "", 90 | "description": "true", 91 | "fetch_retries": "2", 92 | "heading": "npm", 93 | "init_version": "1.0.0", 94 | "user": "1000", 95 | "allow_same_version": "", 96 | "if_present": "", 97 | "prefer_online": "", 98 | "force": "", 99 | "only": "", 100 | "read_only": "", 101 | "cache_min": "10", 102 | "init_license": "ISC", 103 | "editor": "vi", 104 | "rollback": "true", 105 | "tag_version_prefix": "v", 106 | "cache_max": "Infinity", 107 | "userconfig": "/home/sudesh/.npmrc", 108 | "timing": "", 109 | "tmp": "/tmp", 110 | "engine_strict": "", 111 | "init_author_name": "", 112 | "init_author_url": "", 113 | "preid": "", 114 | "depth": "Infinity", 115 | "package_lock_only": "", 116 | "save_dev": "", 117 | "usage": "", 118 | "metrics_registry": "https://registry.npmjs.org/", 119 | "package_lock": "true", 120 | "progress": "true", 121 | "otp": "", 122 | "https_proxy": "", 123 | "save_prod": "", 124 | "audit": "true", 125 | "sso_type": "oauth", 126 | "cidr": "", 127 | "onload_script": "", 128 | "rebuild_bundle": "true", 129 | "shell": "/bin/bash", 130 | "save_bundle": "", 131 | "prefix": "/home/sudesh/leetcode-rating-predictor/services/predict-addon", 132 | "format_package_lock": "true", 133 | "dry_run": "", 134 | "scope": "", 135 | "registry": "https://registry.npmjs.org/", 136 | "cache_lock_wait": "10000", 137 | "browser": "", 138 | "ignore_prepublish": "", 139 | "save_optional": "", 140 | "searchopts": "", 141 | "versions": "", 142 | "cache": "/home/sudesh/.npm", 143 | "send_metrics": "", 144 | "global_style": "", 145 | "ignore_scripts": "", 146 | "version": "", 147 | "viewer": "man", 148 | "node_gyp": "/home/sudesh/.nvm/versions/node/v14.17.3/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js", 149 | "local_address": "", 150 | "audit_level": "low", 151 | "prefer_offline": "", 152 | "color": "true", 153 | "sign_git_commit": "", 154 | "fetch_retry_mintimeout": "10000", 155 | "maxsockets": "50", 156 | "sso_poll_frequency": "500", 157 | "offline": "", 158 | "umask": "0002", 159 | "fund": "true", 160 | "fetch_retry_maxtimeout": "60000", 161 | "logs_max": "10", 162 | "message": "%s", 163 | "ca": "", 164 | "cert": "", 165 | "global": "", 166 | "link": "", 167 | "save": "true", 168 | "access": "", 169 | "also": "", 170 | "unicode": "", 171 | "searchlimit": "20", 172 | "unsafe_perm": "true", 173 | "update_notifier": "true", 174 | "before": "", 175 | "long": "", 176 | "production": "", 177 | "auth_type": "legacy", 178 | "node_version": "14.17.3", 179 | "tag": "latest", 180 | "git_tag_version": "true", 181 | "commit_hooks": "true", 182 | "shrinkwrap": "true", 183 | "script_shell": "", 184 | "fetch_retry_factor": "10", 185 | "strict_ssl": "true", 186 | "save_exact": "", 187 | "globalconfig": "/home/sudesh/.nvm/versions/node/v14.17.3/etc/npmrc", 188 | "init_module": "/home/sudesh/.npm-init.js", 189 | "dev": "", 190 | "parseable": "", 191 | "globalignorefile": "/home/sudesh/.nvm/versions/node/v14.17.3/etc/npmignore", 192 | "cache_lock_retries": "10", 193 | "searchstaleness": "900", 194 | "save_prefix": "^", 195 | "scripts_prepend_node_path": "warn-only", 196 | "node_options": "", 197 | "group": "1000", 198 | "init_author_email": "", 199 | "searchexclude": "", 200 | "git": "git", 201 | "optional": "true", 202 | "json": "" 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |

Leetcode Rating Predictor

7 |
8 | Get your rating changes right after the completion of Leetcode contests 9 |
10 |
11 |
12 |
13 | 20 |
21 | 25 | Dark Mode! 26 |
27 |
28 | 31 |
32 |
33 | 34 |
35 |
36 | 42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | <% for( let contest of contests ) { %> 56 | 57 | 66 | 69 | 72 | 75 | 78 | 81 | 82 | <% } %> 83 | 84 |
Contest NameStart TimeDurationRankings FetchedPredictedLast Updated
58 | <% if (contest.rankings_fetched) { %> 59 | 60 | <%= contest._id %> 61 | 62 | <% } else {%> 63 | <%= contest._id %> 64 | <% } %> 65 | 67 | <%= contest.startTime %> 68 | 70 | <%= (contest.endTime - contest.startTime)/60000 %> minutes 71 | 73 | <%= contest.rankings_fetched?"Yes":"No" %> 74 | 76 | <%= contest.ratings_predicted?"Yes":"No" %> 77 | 79 | <%= contest.lastUpdated %> 80 |
85 |
86 |
87 |
88 |
89 |
90 | 91 |
92 | 93 |
94 | 95 |
96 |

Frequently Asked Questions

97 | 98 |
99 | 104 | 105 |
106 |

It takes about 4-5 days for leetcode to update the contest ratings of participants. So you have to wait for a long time to know your rating changes. This application predicts accurate leetcode rating changes for all the contestants within a few minutes of completion of the contest. 107 | 108 |

109 |
110 |
111 | 112 | 113 | 114 |
115 | 120 | 121 |
122 |

123 | It implements leetcode's latest rating prediction algorithm. Rating predictions are very close to the original rating but the accuracy may not be 100% due to changes in contest rankings after the completion of contest (leetcode rejudges some submissons). 124 |

125 |
126 |
127 | 128 |
129 | 134 | 135 |
136 |

It takes around 15 minutes after the completion of contest to make predictions. Sometimes leetcode takes more time to update the final rankings. So it also rejudges the ratings after 1 hour of the contest.

137 |
138 |
139 |
140 | 145 | 146 |
147 |

You need to install LC Predictor extension from Chrome Web Store. After installing the extension you will see the rating changes on the Leetcode contest ranking pages. 149 |

150 |
151 |
152 | 153 |
154 | 158 | 159 |
160 |

You can contribute by creating issues, feature/ pull requests in the GitHub Repo. Any meaningful contributions you make are greatly appreciated. 161 |

162 |
163 |
164 |
165 | 166 |
167 | 168 | 209 | -------------------------------------------------------------------------------- /chrome-extension/foreground.js: -------------------------------------------------------------------------------- 1 | if (!window.LCPredictorInjected) { 2 | window.LCPredictorInjected = true; 3 | let predictionsTimer; 4 | let isListenerActive = false; 5 | const setEventListener = () => { 6 | try { 7 | const tbody = document.querySelector("tbody"); 8 | if (!tbody) { 9 | setTimeout(setEventListener, 100); 10 | return; 11 | } 12 | const trs = tbody.querySelectorAll("tr"); 13 | if (!trs) { 14 | setTimeout(setEventListener, 100); 15 | return; 16 | } 17 | if (trs.length <= 1) { 18 | // listen only if there is more than one row 19 | isListenerActive = false; 20 | return; 21 | } 22 | const tds = trs[1].querySelectorAll("td"); 23 | if (tds.length <= 1) { 24 | isListenerActive = false; 25 | return; 26 | } 27 | if (isListenerActive) return; 28 | isListenerActive = true; 29 | tds[1].addEventListener("DOMCharacterDataModified", async () => { 30 | window.clearTimeout(predictionsTimer); 31 | predictionsTimer = setTimeout(fetchPredictions, 500); 32 | }); 33 | } catch (err) { 34 | console.error(err); 35 | } 36 | }; 37 | 38 | const isContestRankingPage = (url) => { 39 | return /^https:\/\/leetcode.com\/contest\/.*\/ranking/.test(url); 40 | }; 41 | 42 | let rowsChanged = new Map(); 43 | let deltaTHInserted = false; 44 | 45 | const fetchPredictions = async () => { 46 | const thead = document.querySelector("thead"); 47 | if (!thead) { 48 | predictionsTimer = setTimeout(fetchPredictions, 500); 49 | return; 50 | } 51 | 52 | const tbody = document.querySelector("tbody"); 53 | if (!tbody) { 54 | predictionsTimer = setTimeout(fetchPredictions, 500); 55 | return; 56 | } 57 | 58 | const rows = tbody.querySelectorAll("tr"); 59 | if (!rows) { 60 | predictionsTimer = setTimeout(fetchPredictions, 500); 61 | return; 62 | } 63 | let contestId; 64 | try { 65 | contestId = document 66 | .querySelector(".ranking-title-wrapper") 67 | .querySelector("span") 68 | .querySelector("a") 69 | .innerHTML.toLowerCase() 70 | .replace(/\s/g, "-"); 71 | } catch { 72 | predictionsTimer = setTimeout(fetchPredictions, 500); 73 | return; 74 | } 75 | const handlesMap = new Map(); 76 | 77 | const handles = [...rows].map((row, index) => { 78 | try { 79 | const tds = row.querySelectorAll("td"); 80 | if (tds.length >= 2) { 81 | let handle, url; 82 | try { 83 | handle = tds[1].querySelector("a").innerText.trim(); 84 | url = tds[1].querySelector("a").getAttribute("href"); 85 | } catch { 86 | handle = tds[1].querySelector("span").innerText.trim(); 87 | url = ""; // TODO: get data_region in this case 88 | } 89 | const data_region = /^https:\/\/leetcode.cn/.test(url) 90 | ? "CN" 91 | : "US"; 92 | handlesMap.set( 93 | (data_region + "/" + handle).toLowerCase(), 94 | index 95 | ); 96 | return handle; 97 | } 98 | } catch (err) { 99 | console.debug(err); 100 | } 101 | }); 102 | 103 | chrome.runtime.sendMessage( 104 | { 105 | message: "get_predictions", 106 | data: { 107 | contestId, 108 | handles, 109 | }, 110 | }, 111 | (response) => { 112 | if (!response) { 113 | return; 114 | } 115 | try { 116 | if (response.status === "OK") { 117 | if (!deltaTHInserted && response.meta.total_count) { 118 | const th = document.createElement("th"); 119 | th.innerText = "Δ"; 120 | thead.querySelector("tr").appendChild(th); 121 | deltaTHInserted = true; 122 | } 123 | const rowsUpdated = new Map(); 124 | for (item of response.items) { 125 | try { 126 | const id = ( 127 | item.data_region + 128 | "/" + 129 | item._id 130 | ).toLowerCase(); 131 | if (handlesMap.has(id)) { 132 | const rowIndex = handlesMap.get(id); 133 | const row = rows[rowIndex]; 134 | let td; 135 | if (rowsChanged.has(rowIndex)) { 136 | td = row.lastChild; 137 | } else { 138 | td = document.createElement("td"); 139 | } 140 | if (item.delta == null) { 141 | td.innerText = "?"; 142 | td.style.color = "gray"; 143 | } else { 144 | const delta = 145 | Math.round(item.delta * 100) / 100; 146 | td.innerText = 147 | delta > 0 ? "+" + delta : delta; 148 | if (delta > 0) { 149 | td.style.color = "green"; 150 | } else { 151 | td.style.color = "gray"; 152 | } 153 | // td.style.fontWeight = "bold"; 154 | } 155 | if (!rowsChanged.has(rowIndex)) { 156 | row.appendChild(td); 157 | } 158 | rowsUpdated.set(rowIndex, true); 159 | } else { 160 | console.log( 161 | `handle not found in the results: ${id}` 162 | ); 163 | } 164 | } catch (err) { 165 | console.warn(err); 166 | } 167 | } 168 | for (rowIndex of rowsChanged.keys()) { 169 | if ( 170 | !rowsUpdated.has(rowIndex) && 171 | rowIndex < rows.length 172 | ) { 173 | try { 174 | const row = rows[rowIndex]; 175 | row.lastChild.innerText = ""; 176 | } catch {} 177 | } 178 | if (rowIndex >= rows.length) { 179 | rowsChanged.delete(rowIndex); 180 | } 181 | } 182 | for (rowIndex of rowsUpdated.keys()) { 183 | rowsChanged.set(rowIndex, true); 184 | } 185 | } 186 | } catch (err) { 187 | console.warn(err); 188 | } 189 | } 190 | ); 191 | }; 192 | 193 | // listen to url changes 194 | chrome.runtime.onMessage.addListener(function ( 195 | request, 196 | sender, 197 | sendResponse 198 | ) { 199 | if (request.message === "url_updated") { 200 | if (!isListenerActive && isContestRankingPage(request.url)) { 201 | window.clearTimeout(predictionsTimer); 202 | predictionsTimer = setTimeout(fetchPredictions, 500); 203 | } 204 | if (!isContestRankingPage(request.url)) { 205 | isListenerActive = false; 206 | rowsChanged.clear(); 207 | deltaTHInserted = false; 208 | } else { 209 | setEventListener(); 210 | } 211 | } 212 | }); 213 | } 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 | Logo 6 | 7 |

8 | 9 |
10 |

LC Predictor

11 | Want to see your leetcode rating change right after the contest? Well, you are in the right place! 12 |
13 |
14 |
15 | 16 |
17 |
18 | Chrome Web Store 20 | 21 | 22 |
23 | 24 |
25 | license 27 | 28 | 29 | 30 | 31 | chrome-webstore 32 | 33 | 34 | users 35 | 36 | 37 |
38 |
39 | 40 | # About 41 | 42 | It takes about 4-5 days for leetcode to update the contest ratings of participants. So you have to wait for a long time to know your rating changes. This application predicts accurate leetcode rating changes for all the contestants within a few minutes of completion of the contest. 43 | 44 | # Getting started 45 | 46 | This project consists of two types of user interfaces. You can either use browser extension or the website to get your rating changes. 47 | 48 | ## Chrome extension 49 | 50 | You can install the extension from [Chrome Web Store](https://chrome.google.com/webstore/detail/lc-predictor/jfhgaegpgiepniiebglgjhhfnjcibphh). It adds the rating changes on leetcode ranking pages itself. 51 | 52 |
53 | extension preview 54 |
55 | 56 | ## Website 57 | 58 | You can also visit [lcpredictor.onrender.com](https://lcpredictor.onrender.com/) to get your rating changes. 59 | 60 |
61 | website preview 62 |
63 | 64 | # How It Works 65 | 66 | This project is written in Node + MongoDB + Redis tech stack. We can divide it into three microservices. 67 | 68 | | # | Name | Languages | 69 | | :-: | :--------: | :-------: | 70 | | 1. | Background | Js, Cpp | 71 | | 2. | Website | Js, Ejs | 72 | | 3. | API | Js | 73 | 74 | ## Background 75 | 76 | It is the most important part of the project. It's job is to fetch the data from leetcode and predict the contest ratings periodically. 77 | 78 | ### Rating prediction 79 | 80 | Rating prediction is a cpu intensive task. Therefore a [**nodejs C++ addon**](https://nodejs.org/api/addons.html) is implemented for this task so that we can utilize threading with better performance using **C++**. For performance measurement we got these results : 81 | 82 | | | No. of Threads | Contest | Time taken to make predictions(s) | 83 | | :-------: | :------------: | :----------------: | :-------------------------------: | 84 | | Js | 1 | Weekly contest 242 | 186.589 | 85 | | C++ addon | 1 | Weekly contest 242 | 39.607 | 86 | | C++ addon | 2 | Weekly contest 242 | 19.963 | 87 | | C++ addon | 4 | Weekly contest 242 | 11.401 | 88 | | C++ addon | 8 | Weekly contest 242 | 20.304 | 89 | 90 | #### Machine configuration : 91 | 92 | | Property | Value | 93 | | :-------- | :-------------------------------------- | 94 | | Processor | Intel® Core™ i5-8250U CPU @ 1.60GHz × 8 | 95 | | Memory | 7.7 GB | 96 | | OS | Ubuntu 21.04 | 97 | 98 | It implements leetcode's latest rating prediction algorithm. Rating predictions are very close to the original rating but the accuracy may not be 100% due to changes in contest rankings after the completion of contest (leetcode rejudges some submissons). 99 | 100 | These are the results for the predictions of weekly-contest-242: 101 | 102 | | Measure | Value | 103 | | :-------: | :----------------: | 104 | | MSE | 167.7947072739485 | 105 | | R-squared | 0.9988091420057561 | 106 | 107 | ### Job scheduling 108 | 109 | Job scheduling is required for processing jobs on desired time. Leetcode contests are weekly and biweekly. We can schedule them by scheduling a repeated job. But for making it more generic, job schedulers are implemented who schedules prediction and data update jobs. These job schedulers are scheduled as a repeated job. It is accomplished by using [bull](https://github.com/OptimalBits/bull), a redis based queue for Node. A bull dashboard is also integrated using [bull-board](https://github.com/felixmosh/bull-board). 110 | 111 |
112 | bull dashboard 113 | bull dashboard 114 |
115 | 116 | ## Website 117 | 118 | It is built using [express](https://expressjs.com/) framework. Ejs is used for writing templates. It contains a table for contests and ranking pages with predicted rating changes for all the contests. Pagination is added to ranking pages for better user experience and performace. 119 | 120 | ## API 121 | 122 | It is also implemented using [express](https://expressjs.com/) framework. It contains an endpoint for fetching users' 123 | predicted rating changes which is used in the browser extension. 124 | 125 | IP based rate limit is enforced for both the API and the website using [express-rate-limit](https://github.com/nfriedly/express-rate-limit). 126 | 127 | # Development 128 | 129 | ## Setup 130 | 131 | - Clone the repository 132 | ```bash 133 | git clone https://github.com/SysSn13/leetcode-rating-predictor 134 | ``` 135 | - Install the dependencies 136 | ```bash 137 | npm install 138 | ``` 139 | - Setup environment variables 140 | 141 | ```bash 142 | cp .env.example .env 143 | ``` 144 | 145 | Fill in the required values in the `.env` file. 146 | 147 | - Build the predict-addon (if you are using different node version) 148 | ```bash 149 | npm run buildAddon 150 | ``` 151 | - Start the project 152 | ```bash 153 | npm start 154 | ``` 155 | - Or start the development server by: 156 | ```bash 157 | npm run dev 158 | ``` 159 | 160 | ## Environment variables 161 | 162 | ``` 163 | DATABASE_URL: Connection string for mongodb. 164 | 165 | # for web 166 | WEB: Whether to run the website or not. (0 or 1) 167 | 168 | RATE_LIMIT_WINDOW: Window size for rate limit in milliseconds (default: 10000). 169 | 170 | RATE_LIMIT: Number of requests allowed in the window (default: 50). 171 | 172 | 173 | # for api 174 | API_DISABLED: Whether to disable the API or not. (0 or 1) 175 | 176 | API_RATE_LIMIT_WINDOW: Window size for API rate limit in milliseconds (default: 10000). 177 | 178 | API_RATE_LIMIT: Number of API requests allowed in the window (default: 20). 179 | 180 | 181 | # for background 182 | BACKGROUND: Whether to run the background or not. (0 or 1) 183 | 184 | REDIS_URL: Connection string for redis. 185 | 186 | THREAD_CNT: Number of threads for prediction.(default: 4) 187 | 188 | # bull-board auth 189 | BULLBOARD_USERNAME: username for bull-board login 190 | 191 | BULLBOARD_PASS: password for bull-board login 192 | 193 | SESSION_SECRET: secret to hash the session 194 | 195 | ``` 196 | 197 | ## Browser extension 198 | 199 | Current only chrome browser is supported. It uses manifest V3. See [this](https://developer.chrome.com/docs/extensions/mv3/getstarted/) for getting started with extension development. Source code for the extension is in `./chrome-extension`. 200 | 201 | ## Contributing 202 | 203 | You can contribute by creating issues, feature/ pull requests. Any meaningful contributions you make are greatly appreciated. 204 | 205 | For contributing in the source code, please follow these steps: 206 | 207 | - Fork the Project 208 | - Create your new Branch 209 | ```bash 210 | git checkout -b feature/AmazingFeature 211 | ``` 212 | - Commit your Changes 213 | ``` 214 | git commit -m 'Add some AmazingFeature' 215 | ``` 216 | - Push to the Branch 217 | ``` 218 | git push origin feature/AmazingFeature 219 | ``` 220 | - Open a Pull Request 221 | 222 | # Contributors 223 | 224 | 225 | 226 | 227 | # Stargazers 228 | [![Stargazers repo roster for @SysSn13/leetcode-rating-predictor](https://reporoster.com/stars/SysSn13/leetcode-rating-predictor)](https://github.com/SysSn13/leetcode-rating-predictor/stargazers) 229 | 230 | # License 231 | 232 | Distributed under the MIT License. See [LICENSE](/LICENSE) for more information. 233 | 234 |
235 |

Back to top

236 | -------------------------------------------------------------------------------- /services/contests.js: -------------------------------------------------------------------------------- 1 | const Bottleneck = require("bottleneck"); 2 | const fetch = require("node-fetch"); 3 | const Contest = require("../models/contest"); 4 | const { IsLatestContest } = require("../helpers"); 5 | const { BASE_CN_URL, BASE_URL } = require("./users"); 6 | 7 | const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 8 | 9 | const getContestParticipantsRankings = async (contest, dataRegion) => { 10 | // Create a rate limiter for API calls 11 | const limiter = new Bottleneck({ maxConcurrent: 10, minTime: 100 }); 12 | const contestSlug = contest._id; 13 | console.log(`Fetching participant's rankings for ${dataRegion} region...`); 14 | const baseUrl = dataRegion === "CN" ? BASE_CN_URL : BASE_URL; 15 | let resp = await fetch( 16 | `${baseUrl}/contest/api/ranking/${contestSlug}/?region=global` 17 | ); 18 | resp = await resp.json(); 19 | let pages = Math.ceil(resp.user_num / 25); 20 | let all_rankings = []; 21 | let failed = []; 22 | let lastPage = Number.MAX_SAFE_INTEGER; 23 | const fetchPageRankings = async ( 24 | pageNo, 25 | retries, 26 | throwError = false 27 | ) => { 28 | if (pageNo > lastPage) { 29 | return; 30 | } 31 | try { 32 | let res = await limiter.schedule(() => fetch( 33 | `${baseUrl}/contest/api/ranking/${contestSlug}/?pagination=${pageNo}®ion=${dataRegion == "CN" ? "local" : "global"}` 34 | )); 35 | if (res.status !== 200) { 36 | if (res.status === 429) { 37 | // wait for some time in case of too many requests error 38 | await wait(500); 39 | } 40 | throw new Error(res.statusText); 41 | } 42 | res = await res.json(); 43 | rankings = res.total_rank 44 | .filter( 45 | (ranks) => 46 | !( 47 | ranks.score == 0 && 48 | ranks.finish_time * 1000 == 49 | contest.startTime.getTime() 50 | ) 51 | ); 52 | if (rankings.length < 25) { 53 | lastPage = Math.min(lastPage, pageNo); 54 | } 55 | all_rankings = all_rankings.concat(rankings); 56 | console.log( 57 | `Fetched rankings (${contestSlug} page: ${pageNo})`, 58 | ); 59 | } catch (err) { 60 | console.error(err); 61 | if (retries > 0) { 62 | await fetchPageRankings(pageNo, retries - 1); 63 | } else if (throwError) { 64 | throw err; 65 | } else { 66 | failed.push(pageNo); 67 | } 68 | } 69 | }; 70 | 71 | const maxRetries = 3; 72 | let promises = []; 73 | for (let i = 0; i < pages && i < lastPage; i++) { 74 | promises.push(fetchPageRankings(i + 1, maxRetries)); 75 | } 76 | await Promise.all(promises); 77 | let failedCopy = failed; 78 | failed = []; 79 | promises = []; 80 | 81 | // Set the max concurrent requests to 5 82 | limiter.updateSettings({ maxConcurrent: 5 }); 83 | 84 | for (let i = 0; i < failedCopy.length; i++) { 85 | promises.push(await fetchPageRankings(failedCopy[i], maxRetries)); 86 | } 87 | 88 | await Promise.all(promises); 89 | failedCopy = failed; 90 | failed = []; 91 | promises = []; 92 | // Set the max concurrent requests to 2 93 | limiter.updateSettings({ maxConcurrent: 2 }); 94 | 95 | for (let i = 0; i < failedCopy.length; i++) { 96 | promises.push(await fetchPageRankings(failedCopy[i], maxRetries)); 97 | } 98 | await Promise.all(promises); 99 | failedCopy = failed; 100 | failed = []; 101 | 102 | for (let i = 0; i < failedCopy.length; i++) { 103 | await fetchPageRankings(failedCopy[i], maxRetries, true); 104 | } 105 | 106 | console.log(`(${contestSlug}) Rankings fetched from ${baseUrl}`); 107 | return all_rankings; 108 | }; 109 | 110 | const mergeRankings = (us_rankings, cn_rankings) => { 111 | us_rankings.sort((a, b) => (a.rank > b.rank ? 1 : -1)); 112 | cn_rankings.sort((a, b) => (a.rank > b.rank ? 1 : -1)); 113 | let totalUsRankings = us_rankings.length; 114 | let totalCnRankings = cn_rankings.length; 115 | let currRank = 0; 116 | let i = 0, j = 0; 117 | all_rankings = []; 118 | while (i < totalUsRankings || j < totalCnRankings) { 119 | let currRanking; 120 | if (i == totalUsRankings) { 121 | currRanking = cn_rankings[j++]; 122 | } else if (j == totalCnRankings) { 123 | currRanking = us_rankings[i++]; 124 | } else { 125 | if (us_rankings[i].score > cn_rankings[j].score || (us_rankings[i].score === cn_rankings[j].score && us_rankings[i].finish_time <= cn_rankings[j].finish_time)) { 126 | currRanking = us_rankings[i++]; 127 | } else { 128 | currRanking = cn_rankings[j++]; 129 | } 130 | } 131 | currRanking.rank = currRank++; 132 | all_rankings.push(currRanking); 133 | } 134 | return all_rankings; 135 | } 136 | 137 | const fetchContestRankings = async function (contestSlug) { 138 | try { 139 | let contest = await Contest.findById(contestSlug); 140 | if (!contest) { 141 | return [null, Error(`Contest ${contestSlug} not found in the db`)]; 142 | } 143 | if (!contest.refetch_rankings && contest.rankings_fetched) { 144 | return [contest, null]; 145 | } 146 | contest.rankings = []; 147 | console.log(`fetching ${contestSlug} ...`); 148 | 149 | us_rankings = await getContestParticipantsRankings(contest, "US"); 150 | cn_rankings = await getContestParticipantsRankings(contest, "CN"); 151 | 152 | // Merged rankings sorted by rank 153 | all_rankings = mergeRankings(us_rankings, cn_rankings).map((ranking) => { 154 | let { 155 | username, 156 | user_slug, 157 | country_code, 158 | country_name, 159 | data_region, 160 | rank, 161 | } = ranking; 162 | if (data_region === "CN") { 163 | country_code = "CN"; 164 | } 165 | return { 166 | _id: username, 167 | username, 168 | user_slug, 169 | country_code, 170 | country_name, 171 | data_region, 172 | rank, 173 | };; 174 | }); 175 | 176 | contest.rankings = all_rankings; 177 | contest.rankings_fetched = true; 178 | contest.refetch_rankings = false; 179 | contest.user_num = all_rankings.length; 180 | console.time(`Saving rankings in db (${contestSlug})`); 181 | await contest.save(); 182 | console.timeEnd(`Saving rankings in db (${contestSlug})`); 183 | console.log(`Updated rankings in db (${contestSlug}).`); 184 | 185 | return [contest, null]; 186 | } catch (err) { 187 | return [null, err]; 188 | } 189 | }; 190 | const fetchContestsMetaData = async () => { 191 | console.log("fetching meta data for all contests..."); 192 | try { 193 | let res = await fetch("https://leetcode.com/graphql", { 194 | headers: { 195 | accept: "*/*", 196 | "accept-language": "en-GB,en-US;q=0.9,en;q=0.8", 197 | "content-type": "application/json", 198 | "sec-ch-ua": 199 | '" Not A;Brand";v="99", "Chromium";v="90", "Google Chrome";v="90"', 200 | "sec-ch-ua-mobile": "?0", 201 | "sec-fetch-dest": "empty", 202 | "sec-fetch-mode": "cors", 203 | "sec-fetch-site": "same-origin", 204 | }, 205 | referrer: "https://leetcode.com/contest/", 206 | referrerPolicy: "strict-origin-when-cross-origin", 207 | body: `{"operationName":null,"variables":{},"query":"{\ 208 | currentTimestamp\ 209 | allContests {\ 210 | containsPremium\ 211 | title\ 212 | titleSlug\ 213 | startTime\ 214 | duration\ 215 | originStartTime\ 216 | isVirtual\ 217 | }\ 218 | }\ 219 | "}`, 220 | method: "POST", 221 | mode: "cors", 222 | }); 223 | res = await res.json(); 224 | 225 | for (let i = 0; i < res.data.allContests.length; i++) { 226 | let contest = res.data.allContests[i]; 227 | const endTime = contest.startTime * 1000 + contest.duration * 1000; 228 | if (!IsLatestContest(endTime)) { 229 | continue; 230 | } 231 | let contestExists = await Contest.exists({ 232 | _id: contest.titleSlug, 233 | }); 234 | if (contestExists) { 235 | continue; 236 | } 237 | let newContest = new Contest({ 238 | _id: contest.titleSlug, 239 | title: contest.title, 240 | startTime: contest.startTime * 1000, 241 | endTime: endTime, 242 | lastUpdated: Date.now(), 243 | }); 244 | await newContest.save(); 245 | console.log(`created new contest: ${contest.titleSlug}`); 246 | } 247 | console.log("Fetched contests' meta data"); 248 | return res.data.allContests; 249 | } catch (err) { 250 | console.error(err); 251 | return null; 252 | } 253 | }; 254 | 255 | // exports 256 | exports.fetchContestsMetaData = fetchContestsMetaData; 257 | exports.fetchContestRankings = fetchContestRankings; 258 | -------------------------------------------------------------------------------- /views/ranking.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |
6 |
7 | All Contests 8 |
9 |
10 | 11 |
12 |
13 | 14 |

Ranking of <%= contest.title || contest._id %> 15 |

16 |
17 |
18 | 19 | 20 |
21 |
22 | 23 | 62 |
63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | <% if (contest.rankings) { %> 77 | <% for( let i = 0; i < contest.rankings.length; i++ ) { %> 78 | 79 | 80 | 85 | 86 | <% if ( contest.rankings[i].current_rating && contest.rankings[i].current_rating != -1) { %> 87 | 88 | 90 | <% } else { %> 91 | 92 | 93 | <% } %> 94 | <% if ( contest.rankings[i].delta != undefined) { %> 95 | 99 | <% } else { %> 100 | 101 | <% } %> 102 | 103 | 104 | 105 | <% } %> 106 | <% } %> 107 | 108 | <% if (contest.rankings.length===0) { %> 109 | 110 | 113 | 114 | <% } %> 115 | 116 | 117 |
#UsernameRankPrevious ratingExpected new ratingΔCountry/Region name
<%= i+1 %> 83 | <%="https://leetcode-cn.com/"%><% } else { %><%="https://leetcode.com/"%><% }%><%=contest.rankings[i]._id%>"><%= contest.rankings[i]._id %> 84 | <%= contest.rankings[i].rank %> <%= Math.round(contest.rankings[i].current_rating*100)/100 %> 89 | <%= Math.round((contest.rankings[i].current_rating+contest.rankings[i].delta)*100)/100 %>?? 97 | <%= (contest.rankings[i].delta>0? "+":"") + Math.round(contest.rankings[i].delta*100)/100 %> 98 | ?<%= contest.rankings[i].country_name %>
111 | No matching records found 112 |
118 | 119 |
120 |
121 | 154 | 155 | -------------------------------------------------------------------------------- /services/predict-addon/build/Makefile: -------------------------------------------------------------------------------- 1 | # We borrow heavily from the kernel build setup, though we are simpler since 2 | # we don't have Kconfig tweaking settings on us. 3 | 4 | # The implicit make rules have it looking for RCS files, among other things. 5 | # We instead explicitly write all the rules we care about. 6 | # It's even quicker (saves ~200ms) to pass -r on the command line. 7 | MAKEFLAGS=-r 8 | 9 | # The source directory tree. 10 | srcdir := .. 11 | abs_srcdir := $(abspath $(srcdir)) 12 | 13 | # The name of the builddir. 14 | builddir_name ?= . 15 | 16 | # The V=1 flag on command line makes us verbosely print command lines. 17 | ifdef V 18 | quiet= 19 | else 20 | quiet=quiet_ 21 | endif 22 | 23 | # Specify BUILDTYPE=Release on the command line for a release build. 24 | BUILDTYPE ?= Release 25 | 26 | # Directory all our build output goes into. 27 | # Note that this must be two directories beneath src/ for unit tests to pass, 28 | # as they reach into the src/ directory for data with relative paths. 29 | builddir ?= $(builddir_name)/$(BUILDTYPE) 30 | abs_builddir := $(abspath $(builddir)) 31 | depsdir := $(builddir)/.deps 32 | 33 | # Object output directory. 34 | obj := $(builddir)/obj 35 | abs_obj := $(abspath $(obj)) 36 | 37 | # We build up a list of every single one of the targets so we can slurp in the 38 | # generated dependency rule Makefiles in one pass. 39 | all_deps := 40 | 41 | 42 | 43 | CC.target ?= $(CC) 44 | CFLAGS.target ?= $(CPPFLAGS) $(CFLAGS) 45 | CXX.target ?= $(CXX) 46 | CXXFLAGS.target ?= $(CPPFLAGS) $(CXXFLAGS) 47 | LINK.target ?= $(LINK) 48 | LDFLAGS.target ?= $(LDFLAGS) 49 | AR.target ?= $(AR) 50 | 51 | # C++ apps need to be linked with g++. 52 | LINK ?= $(CXX.target) 53 | 54 | # TODO(evan): move all cross-compilation logic to gyp-time so we don't need 55 | # to replicate this environment fallback in make as well. 56 | CC.host ?= gcc 57 | CFLAGS.host ?= $(CPPFLAGS_host) $(CFLAGS_host) 58 | CXX.host ?= g++ 59 | CXXFLAGS.host ?= $(CPPFLAGS_host) $(CXXFLAGS_host) 60 | LINK.host ?= $(CXX.host) 61 | LDFLAGS.host ?= 62 | AR.host ?= ar 63 | 64 | # Define a dir function that can handle spaces. 65 | # http://www.gnu.org/software/make/manual/make.html#Syntax-of-Functions 66 | # "leading spaces cannot appear in the text of the first argument as written. 67 | # These characters can be put into the argument value by variable substitution." 68 | empty := 69 | space := $(empty) $(empty) 70 | 71 | # http://stackoverflow.com/questions/1189781/using-make-dir-or-notdir-on-a-path-with-spaces 72 | replace_spaces = $(subst $(space),?,$1) 73 | unreplace_spaces = $(subst ?,$(space),$1) 74 | dirx = $(call unreplace_spaces,$(dir $(call replace_spaces,$1))) 75 | 76 | # Flags to make gcc output dependency info. Note that you need to be 77 | # careful here to use the flags that ccache and distcc can understand. 78 | # We write to a dep file on the side first and then rename at the end 79 | # so we can't end up with a broken dep file. 80 | depfile = $(depsdir)/$(call replace_spaces,$@).d 81 | DEPFLAGS = -MMD -MF $(depfile).raw 82 | 83 | # We have to fixup the deps output in a few ways. 84 | # (1) the file output should mention the proper .o file. 85 | # ccache or distcc lose the path to the target, so we convert a rule of 86 | # the form: 87 | # foobar.o: DEP1 DEP2 88 | # into 89 | # path/to/foobar.o: DEP1 DEP2 90 | # (2) we want missing files not to cause us to fail to build. 91 | # We want to rewrite 92 | # foobar.o: DEP1 DEP2 \ 93 | # DEP3 94 | # to 95 | # DEP1: 96 | # DEP2: 97 | # DEP3: 98 | # so if the files are missing, they're just considered phony rules. 99 | # We have to do some pretty insane escaping to get those backslashes 100 | # and dollar signs past make, the shell, and sed at the same time. 101 | # Doesn't work with spaces, but that's fine: .d files have spaces in 102 | # their names replaced with other characters. 103 | define fixup_dep 104 | # The depfile may not exist if the input file didn't have any #includes. 105 | touch $(depfile).raw 106 | # Fixup path as in (1). 107 | sed -e "s|^$(notdir $@)|$@|" $(depfile).raw >> $(depfile) 108 | # Add extra rules as in (2). 109 | # We remove slashes and replace spaces with new lines; 110 | # remove blank lines; 111 | # delete the first line and append a colon to the remaining lines. 112 | sed -e 's|\\||' -e 'y| |\n|' $(depfile).raw |\ 113 | grep -v '^$$' |\ 114 | sed -e 1d -e 's|$$|:|' \ 115 | >> $(depfile) 116 | rm $(depfile).raw 117 | endef 118 | 119 | # Command definitions: 120 | # - cmd_foo is the actual command to run; 121 | # - quiet_cmd_foo is the brief-output summary of the command. 122 | 123 | quiet_cmd_cc = CC($(TOOLSET)) $@ 124 | cmd_cc = $(CC.$(TOOLSET)) $(GYP_CFLAGS) $(DEPFLAGS) $(CFLAGS.$(TOOLSET)) -c -o $@ $< 125 | 126 | quiet_cmd_cxx = CXX($(TOOLSET)) $@ 127 | cmd_cxx = $(CXX.$(TOOLSET)) $(GYP_CXXFLAGS) $(DEPFLAGS) $(CXXFLAGS.$(TOOLSET)) -c -o $@ $< 128 | 129 | quiet_cmd_touch = TOUCH $@ 130 | cmd_touch = touch $@ 131 | 132 | quiet_cmd_copy = COPY $@ 133 | # send stderr to /dev/null to ignore messages when linking directories. 134 | cmd_copy = rm -rf "$@" && cp -af "$<" "$@" 135 | 136 | quiet_cmd_alink = AR($(TOOLSET)) $@ 137 | cmd_alink = rm -f $@ && $(AR.$(TOOLSET)) crs $@ $(filter %.o,$^) 138 | 139 | quiet_cmd_alink_thin = AR($(TOOLSET)) $@ 140 | cmd_alink_thin = rm -f $@ && $(AR.$(TOOLSET)) crsT $@ $(filter %.o,$^) 141 | 142 | # Due to circular dependencies between libraries :(, we wrap the 143 | # special "figure out circular dependencies" flags around the entire 144 | # input list during linking. 145 | quiet_cmd_link = LINK($(TOOLSET)) $@ 146 | cmd_link = $(LINK.$(TOOLSET)) $(GYP_LDFLAGS) $(LDFLAGS.$(TOOLSET)) -o $@ -Wl,--start-group $(LD_INPUTS) $(LIBS) -Wl,--end-group 147 | 148 | # We support two kinds of shared objects (.so): 149 | # 1) shared_library, which is just bundling together many dependent libraries 150 | # into a link line. 151 | # 2) loadable_module, which is generating a module intended for dlopen(). 152 | # 153 | # They differ only slightly: 154 | # In the former case, we want to package all dependent code into the .so. 155 | # In the latter case, we want to package just the API exposed by the 156 | # outermost module. 157 | # This means shared_library uses --whole-archive, while loadable_module doesn't. 158 | # (Note that --whole-archive is incompatible with the --start-group used in 159 | # normal linking.) 160 | 161 | # Other shared-object link notes: 162 | # - Set SONAME to the library filename so our binaries don't reference 163 | # the local, absolute paths used on the link command-line. 164 | quiet_cmd_solink = SOLINK($(TOOLSET)) $@ 165 | cmd_solink = $(LINK.$(TOOLSET)) -shared $(GYP_LDFLAGS) $(LDFLAGS.$(TOOLSET)) -Wl,-soname=$(@F) -o $@ -Wl,--whole-archive $(LD_INPUTS) -Wl,--no-whole-archive $(LIBS) 166 | 167 | quiet_cmd_solink_module = SOLINK_MODULE($(TOOLSET)) $@ 168 | cmd_solink_module = $(LINK.$(TOOLSET)) -shared $(GYP_LDFLAGS) $(LDFLAGS.$(TOOLSET)) -Wl,-soname=$(@F) -o $@ -Wl,--start-group $(filter-out FORCE_DO_CMD, $^) -Wl,--end-group $(LIBS) 169 | 170 | 171 | # Define an escape_quotes function to escape single quotes. 172 | # This allows us to handle quotes properly as long as we always use 173 | # use single quotes and escape_quotes. 174 | escape_quotes = $(subst ','\'',$(1)) 175 | # This comment is here just to include a ' to unconfuse syntax highlighting. 176 | # Define an escape_vars function to escape '$' variable syntax. 177 | # This allows us to read/write command lines with shell variables (e.g. 178 | # $LD_LIBRARY_PATH), without triggering make substitution. 179 | escape_vars = $(subst $$,$$$$,$(1)) 180 | # Helper that expands to a shell command to echo a string exactly as it is in 181 | # make. This uses printf instead of echo because printf's behaviour with respect 182 | # to escape sequences is more portable than echo's across different shells 183 | # (e.g., dash, bash). 184 | exact_echo = printf '%s\n' '$(call escape_quotes,$(1))' 185 | 186 | # Helper to compare the command we're about to run against the command 187 | # we logged the last time we ran the command. Produces an empty 188 | # string (false) when the commands match. 189 | # Tricky point: Make has no string-equality test function. 190 | # The kernel uses the following, but it seems like it would have false 191 | # positives, where one string reordered its arguments. 192 | # arg_check = $(strip $(filter-out $(cmd_$(1)), $(cmd_$@)) \ 193 | # $(filter-out $(cmd_$@), $(cmd_$(1)))) 194 | # We instead substitute each for the empty string into the other, and 195 | # say they're equal if both substitutions produce the empty string. 196 | # .d files contain ? instead of spaces, take that into account. 197 | command_changed = $(or $(subst $(cmd_$(1)),,$(cmd_$(call replace_spaces,$@))),\ 198 | $(subst $(cmd_$(call replace_spaces,$@)),,$(cmd_$(1)))) 199 | 200 | # Helper that is non-empty when a prerequisite changes. 201 | # Normally make does this implicitly, but we force rules to always run 202 | # so we can check their command lines. 203 | # $? -- new prerequisites 204 | # $| -- order-only dependencies 205 | prereq_changed = $(filter-out FORCE_DO_CMD,$(filter-out $|,$?)) 206 | 207 | # Helper that executes all postbuilds until one fails. 208 | define do_postbuilds 209 | @E=0;\ 210 | for p in $(POSTBUILDS); do\ 211 | eval $$p;\ 212 | E=$$?;\ 213 | if [ $$E -ne 0 ]; then\ 214 | break;\ 215 | fi;\ 216 | done;\ 217 | if [ $$E -ne 0 ]; then\ 218 | rm -rf "$@";\ 219 | exit $$E;\ 220 | fi 221 | endef 222 | 223 | # do_cmd: run a command via the above cmd_foo names, if necessary. 224 | # Should always run for a given target to handle command-line changes. 225 | # Second argument, if non-zero, makes it do asm/C/C++ dependency munging. 226 | # Third argument, if non-zero, makes it do POSTBUILDS processing. 227 | # Note: We intentionally do NOT call dirx for depfile, since it contains ? for 228 | # spaces already and dirx strips the ? characters. 229 | define do_cmd 230 | $(if $(or $(command_changed),$(prereq_changed)), 231 | @$(call exact_echo, $($(quiet)cmd_$(1))) 232 | @mkdir -p "$(call dirx,$@)" "$(dir $(depfile))" 233 | $(if $(findstring flock,$(word 1,$(cmd_$1))), 234 | @$(cmd_$(1)) 235 | @echo " $(quiet_cmd_$(1)): Finished", 236 | @$(cmd_$(1)) 237 | ) 238 | @$(call exact_echo,$(call escape_vars,cmd_$(call replace_spaces,$@) := $(cmd_$(1)))) > $(depfile) 239 | @$(if $(2),$(fixup_dep)) 240 | $(if $(and $(3), $(POSTBUILDS)), 241 | $(call do_postbuilds) 242 | ) 243 | ) 244 | endef 245 | 246 | # Declare the "all" target first so it is the default, 247 | # even though we don't have the deps yet. 248 | .PHONY: all 249 | all: 250 | 251 | # make looks for ways to re-generate included makefiles, but in our case, we 252 | # don't have a direct way. Explicitly telling make that it has nothing to do 253 | # for them makes it go faster. 254 | %.d: ; 255 | 256 | # Use FORCE_DO_CMD to force a target to run. Should be coupled with 257 | # do_cmd. 258 | .PHONY: FORCE_DO_CMD 259 | FORCE_DO_CMD: 260 | 261 | TOOLSET := target 262 | # Suffix rules, putting all outputs into $(obj). 263 | $(obj).$(TOOLSET)/%.o: $(srcdir)/%.c FORCE_DO_CMD 264 | @$(call do_cmd,cc,1) 265 | $(obj).$(TOOLSET)/%.o: $(srcdir)/%.cc FORCE_DO_CMD 266 | @$(call do_cmd,cxx,1) 267 | $(obj).$(TOOLSET)/%.o: $(srcdir)/%.cpp FORCE_DO_CMD 268 | @$(call do_cmd,cxx,1) 269 | $(obj).$(TOOLSET)/%.o: $(srcdir)/%.cxx FORCE_DO_CMD 270 | @$(call do_cmd,cxx,1) 271 | $(obj).$(TOOLSET)/%.o: $(srcdir)/%.s FORCE_DO_CMD 272 | @$(call do_cmd,cc,1) 273 | $(obj).$(TOOLSET)/%.o: $(srcdir)/%.S FORCE_DO_CMD 274 | @$(call do_cmd,cc,1) 275 | 276 | # Try building from generated source, too. 277 | $(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.c FORCE_DO_CMD 278 | @$(call do_cmd,cc,1) 279 | $(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.cc FORCE_DO_CMD 280 | @$(call do_cmd,cxx,1) 281 | $(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.cpp FORCE_DO_CMD 282 | @$(call do_cmd,cxx,1) 283 | $(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.cxx FORCE_DO_CMD 284 | @$(call do_cmd,cxx,1) 285 | $(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.s FORCE_DO_CMD 286 | @$(call do_cmd,cc,1) 287 | $(obj).$(TOOLSET)/%.o: $(obj).$(TOOLSET)/%.S FORCE_DO_CMD 288 | @$(call do_cmd,cc,1) 289 | 290 | $(obj).$(TOOLSET)/%.o: $(obj)/%.c FORCE_DO_CMD 291 | @$(call do_cmd,cc,1) 292 | $(obj).$(TOOLSET)/%.o: $(obj)/%.cc FORCE_DO_CMD 293 | @$(call do_cmd,cxx,1) 294 | $(obj).$(TOOLSET)/%.o: $(obj)/%.cpp FORCE_DO_CMD 295 | @$(call do_cmd,cxx,1) 296 | $(obj).$(TOOLSET)/%.o: $(obj)/%.cxx FORCE_DO_CMD 297 | @$(call do_cmd,cxx,1) 298 | $(obj).$(TOOLSET)/%.o: $(obj)/%.s FORCE_DO_CMD 299 | @$(call do_cmd,cc,1) 300 | $(obj).$(TOOLSET)/%.o: $(obj)/%.S FORCE_DO_CMD 301 | @$(call do_cmd,cc,1) 302 | 303 | 304 | ifeq ($(strip $(foreach prefix,$(NO_LOAD),\ 305 | $(findstring $(join ^,$(prefix)),\ 306 | $(join ^,predict_addon.target.mk)))),) 307 | include predict_addon.target.mk 308 | endif 309 | 310 | quiet_cmd_regen_makefile = ACTION Regenerating $@ 311 | cmd_regen_makefile = cd $(srcdir); /home/sudesh/.nvm/versions/node/v14.17.3/lib/node_modules/npm/node_modules/node-gyp/gyp/gyp_main.py -fmake --ignore-environment "-Dlibrary=shared_library" "-Dvisibility=default" "-Dnode_root_dir=/home/sudesh/.cache/node-gyp/14.17.3" "-Dnode_gyp_dir=/home/sudesh/.nvm/versions/node/v14.17.3/lib/node_modules/npm/node_modules/node-gyp" "-Dnode_lib_file=/home/sudesh/.cache/node-gyp/14.17.3/<(target_arch)/node.lib" "-Dmodule_root_dir=/home/sudesh/leetcode-rating-predictor/services/predict-addon" "-Dnode_engine=v8" "--depth=." "-Goutput_dir=." "--generator-output=build" -I/home/sudesh/leetcode-rating-predictor/services/predict-addon/build/config.gypi -I/home/sudesh/.nvm/versions/node/v14.17.3/lib/node_modules/npm/node_modules/node-gyp/addon.gypi -I/home/sudesh/.cache/node-gyp/14.17.3/include/node/common.gypi "--toplevel-dir=." binding.gyp 312 | Makefile: $(srcdir)/../../../.cache/node-gyp/14.17.3/include/node/common.gypi $(srcdir)/../../../.nvm/versions/node/v14.17.3/lib/node_modules/npm/node_modules/node-gyp/addon.gypi $(srcdir)/binding.gyp $(srcdir)/build/config.gypi 313 | $(call do_cmd,regen_makefile) 314 | 315 | # "all" is a concatenation of the "all" targets from all the included 316 | # sub-makefiles. This is just here to clarify. 317 | all: 318 | 319 | # Add in dependency-tracking rules. $(all_deps) is the list of every single 320 | # target in our tree. Only consider the ones with .d (dependency) info: 321 | d_files := $(wildcard $(foreach f,$(all_deps),$(depsdir)/$(f).d)) 322 | ifneq ($(d_files),) 323 | include $(d_files) 324 | endif 325 | -------------------------------------------------------------------------------- /services/users.js: -------------------------------------------------------------------------------- 1 | const Bottleneck = require("bottleneck"); 2 | const { User } = require("../models/user"); 3 | const Contest = require("../models/contest"); 4 | const fetch = require("node-fetch"); 5 | 6 | const BASE_URL = "https://leetcode.com"; 7 | const BASE_CN_URL = "https://leetcode.cn"; 8 | const { getUserId } = require("../helpers"); 9 | 10 | // Create a rate limiter for user API calls 11 | const limiter = new Bottleneck({ maxConcurrent: 10, minTime: 100 }); 12 | 13 | const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 14 | 15 | async function fetchUserDataUSRegion(username, retries = 4, updateDB = true) { 16 | try { 17 | let attendedContestsCount, 18 | rating, 19 | globalRanking, 20 | user_id = getUserId(username, "US"); 21 | var resp = await limiter.schedule(() => fetch(BASE_URL + "/graphql", { 22 | headers: { 23 | accept: "*/*", 24 | "accept-language": 25 | "en-GB,en-US;q=0.9,en;q=0.8,hi;q=0.7,ru;q=0.6", 26 | "cache-control": "no-cache", 27 | "content-type": "application/json", 28 | pragma: "no-cache", 29 | "sec-ch-ua-mobile": "?0", 30 | "sec-ch-ua": 31 | '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"', 32 | "sec-fetch-dest": "empty", 33 | "sec-fetch-mode": "cors", 34 | "sec-fetch-site": "same-origin", 35 | "sec-gpc": "1", 36 | }, 37 | referrer: "https://leetcode.com/", 38 | referrerPolicy: "strict-origin-when-cross-origin", 39 | body: `{"operationName":"getContestRankingData","variables":{"username":"${username}"},"query":"query getContestRankingData($username: String!) {\ 40 | userContestRanking(username: $username) {\ 41 | attendedContestsCount\ 42 | rating\ 43 | globalRanking\ 44 | }\ 45 | }\ 46 | "}`, 47 | method: "POST", 48 | mode: "cors", 49 | })); 50 | 51 | if (resp.status != 200) { 52 | if (resp.status === 429) { 53 | // If the response code is 429 (Too Many Requests), wait for some time 54 | await wait(500); 55 | } 56 | if (retries > 0) { 57 | const res = await fetchUserDataUSRegion( 58 | username, 59 | retries - 1, 60 | updateDB 61 | ); 62 | return res; 63 | } 64 | return [null, new Error(resp.statusText)]; 65 | } 66 | 67 | resp = await resp.json(); 68 | if (resp.errors || !resp.data) { 69 | return [ 70 | { 71 | rating: -1, 72 | isFirstContest: false, 73 | }, 74 | null, 75 | ]; 76 | } 77 | 78 | ranking = resp.data.userContestRanking; 79 | if (ranking) { 80 | attendedContestsCount = ranking.attendedContestsCount; 81 | rating = ranking.rating; 82 | globalRanking = ranking.globalRanking; 83 | } else { 84 | rating = 1500; 85 | attendedContestsCount = 0; 86 | } 87 | const result = { 88 | rating: rating, 89 | isFirstContest: attendedContestsCount === 0, 90 | }; 91 | if (!updateDB) { 92 | return [result, null]; 93 | } 94 | let user = await User.findById(user_id, { _id: 1 }); 95 | const exists = user != null; 96 | if (!exists) { 97 | user = new User({ _id: user_id }); 98 | } 99 | user.attendedContestsCount = attendedContestsCount; 100 | user.rating = rating; 101 | user.globalRanking = globalRanking; 102 | user.lastUpdated = Date.now(); 103 | await user.save(); 104 | 105 | return [result, null]; 106 | } catch (err) { 107 | if (retries > 0) { 108 | const res = await fetchUserDataUSRegion( 109 | username, 110 | retries - 1, 111 | updateDB 112 | ); 113 | return res; 114 | } 115 | return [null, err]; 116 | } 117 | } 118 | 119 | async function fetchUserDataCNRegion(username, retries = 4, updateDB = true) { 120 | try { 121 | let attendedContestsCount, 122 | rating, 123 | globalRanking, 124 | user_id = getUserId(username, "CN"); 125 | let resp = await limiter.schedule(() => fetch(BASE_CN_URL + "/graphql/", { 126 | headers: { 127 | accept: "*/*", 128 | "accept-language": "en", 129 | "cache-control": "no-cache", 130 | "content-type": "application/json", 131 | pragma: "no-cache", 132 | "sec-ch-ua": `" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"`, 133 | "sec-ch-ua-mobile": "?0", 134 | "sec-fetch-dest": "empty", 135 | "sec-fetch-mode": "cors", 136 | "sec-fetch-site": "same-origin", 137 | "sec-gpc": "1", 138 | "x-definition-name": "userProfilePublicProfile", 139 | "x-operation-name": "userPublicProfile", 140 | }, 141 | referrer: "https://leetcode-cn.com", 142 | referrerPolicy: "strict-origin-when-cross-origin", 143 | body: `{"operationName":"userPublicProfile","variables":{"userSlug":"${username}"},"query":"query userPublicProfile($userSlug: String!) {\ 144 | userProfilePublicProfile(userSlug: $userSlug) {\ 145 | username\ 146 | siteRanking\ 147 | profile {\ 148 | userSlug\ 149 | contestCount\ 150 | ranking {\ 151 | rating\ 152 | currentLocalRanking\ 153 | currentGlobalRanking\ 154 | currentRating\ 155 | totalLocalUsers\ 156 | totalGlobalUsers\ 157 | }\ 158 | }\ 159 | }\ 160 | }\ 161 | "}`, 162 | method: "POST", 163 | mode: "cors", 164 | })); 165 | 166 | if (resp.status != 200) { 167 | if (resp.status == 429) { 168 | // If the response code is 429 (Too Many Requests), wait for some time 169 | await wait(500); 170 | } 171 | if (retries > 0) { 172 | const res = await fetchUserDataCNRegion( 173 | username, 174 | retries - 1, 175 | updateDB 176 | ); 177 | return res; 178 | } 179 | return [null, new Error(resp.statusText)]; 180 | } 181 | 182 | resp = await resp.json(); 183 | 184 | if ( 185 | resp.errors || 186 | !resp.data || 187 | !resp.data.userProfilePublicProfile || 188 | !resp.data.userProfilePublicProfile.profile 189 | ) { 190 | return [ 191 | { 192 | rating: -1, 193 | isFirstContest: false, 194 | }, 195 | null, 196 | ]; 197 | } 198 | 199 | let profile = resp.data.userProfilePublicProfile.profile; 200 | attendedContestsCount = parseInt(profile.contestCount); 201 | if (attendedContestsCount > 0) { 202 | let ranking = profile.ranking; 203 | globalRanking = ranking.currentGlobalRanking; 204 | rating = parseFloat(ranking.currentRating); 205 | } else rating = 1500; 206 | 207 | const result = { 208 | isFirstContest: attendedContestsCount === 0, 209 | rating: rating, 210 | }; 211 | if (!updateDB) { 212 | return [result, null]; 213 | } 214 | let user = await User.findById(user_id, { _id: 1 }); 215 | const exists = user != null; 216 | if (!exists) { 217 | user = new User({ _id: user_id }); 218 | } 219 | user.attendedContestsCount = attendedContestsCount; 220 | user.rating = rating; 221 | user.globalRanking = globalRanking; 222 | user.lastUpdated = Date.now(); 223 | await user.save(); 224 | return [result, null]; 225 | } catch (err) { 226 | if (retries > 0) { 227 | const res = await fetchUserDataCNRegion( 228 | username, 229 | retries - 1, 230 | updateDB 231 | ); 232 | return res; 233 | } 234 | return [null, err]; 235 | } 236 | } 237 | 238 | const fetchUserInfo = async (username, dataRegion = "US") => { 239 | if (dataRegion === "CN") { 240 | const [result, err] = await fetchUserDataCNRegion(username); 241 | return [result, err]; 242 | } else { 243 | const [result, err] = await fetchUserDataUSRegion(username); 244 | return [result, err]; 245 | } 246 | }; 247 | 248 | const getCurrentRating = async ( 249 | username, 250 | dataRegion = "US", 251 | checkInDB = true 252 | ) => { 253 | const user_id = getUserId(username, dataRegion); 254 | let result = { 255 | isFirstContest: false, 256 | rating: -1, 257 | }; 258 | try { 259 | let user; 260 | if (checkInDB) { 261 | user = await User.findById(user_id, { contestsHistory: 0 }); 262 | } 263 | if (user) { 264 | result.isFirstContest = user.attendedContestsCount === 0; 265 | result.rating = user.rating; 266 | } else { 267 | const [userInfo, err] = await fetchUserInfo(username, dataRegion); 268 | if (err) { 269 | console.log(`Failed to fetch: ${user_id}`); 270 | result.error = err; 271 | } else { 272 | if (userInfo) { 273 | result = userInfo; 274 | } 275 | } 276 | } 277 | } catch (err) { 278 | result.error = err; 279 | return result; 280 | } 281 | return result; 282 | }; 283 | 284 | // fetches data required for predictions 285 | const getContestParticipantsData = async (contest) => { 286 | try { 287 | if (!contest || !contest.rankings) { 288 | return []; 289 | } 290 | let total = contest.rankings.length; 291 | 292 | let participantsMap = new Map(); 293 | contest.rankings.forEach((rank, index) => { 294 | const id = getUserId(rank._id, rank.data_region); 295 | participantsMap.set(id, index); 296 | }); 297 | let result = new Array(total), 298 | failed = []; 299 | let fetchedCount = 0; 300 | let limit = 500; 301 | 302 | // if there was a contest withing last 24 hours then most probably ratings for last contest are not going to be updated on leetcode 303 | // so it's better to use our predicted ratings for those participants who participated in the last contest 304 | const lowLimit = contest.startTime - 24 * 60 * 60 * 1000; // within 24 hours 305 | const upLimit = contest.startTime; 306 | const lastContest = await Contest.findOne( 307 | { startTime: { $gte: lowLimit, $lt: upLimit } }, 308 | { _id: 1 } 309 | ).sort({ startTime: -1 }); 310 | 311 | // if there was a contest within last 24 hours 312 | if (lastContest) { 313 | // participants' username list 314 | const handles = contest.rankings.map((rank) => { 315 | return rank._id; 316 | }); 317 | 318 | // get rating predictions from last contest 319 | const predictedRatings = await Contest.aggregate([ 320 | { 321 | $project: { 322 | _id: 1, 323 | "rankings._id": 1, 324 | "rankings.current_rating": 1, 325 | "rankings.delta": 1, 326 | "rankings.data_region": 1, 327 | }, 328 | }, 329 | { $match: { _id: lastContest._id } }, 330 | { $unwind: "$rankings" }, 331 | { $match: { "rankings._id": { $in: handles } } }, 332 | ]); 333 | 334 | // add predicted ratings'data in result 335 | if (predictedRatings) { 336 | predictedRatings.map((itm) => { 337 | itm = itm.rankings; 338 | const id = getUserId(itm._id, itm.data_region); 339 | if (participantsMap.has(id)) { 340 | result[participantsMap.get(id)] = { 341 | isFirstContest: false, // always false because user participated in minimum two contests 342 | rating: itm.current_rating + itm.delta, 343 | }; 344 | } 345 | }); 346 | } 347 | } 348 | 349 | const getCurrentRatingHelper = async (index, isFailed = false) => { 350 | let userData = await getCurrentRating( 351 | contest.rankings[index].user_slug, 352 | contest.rankings[index].data_region, 353 | !isFailed 354 | ); 355 | result[index] = userData; 356 | if (userData.error) { 357 | failed.push(index); 358 | } else { 359 | fetchedCount++; 360 | } 361 | }; 362 | 363 | 364 | // TODO: fetch ratings in one query for all the users who are already saved in db 365 | let promises = []; 366 | for (let i = 0; i < total; i++) { 367 | if (result[i]) continue; // skip if already fetched 368 | promises.push(getCurrentRatingHelper(i)); 369 | } 370 | 371 | const logProgressInterval = 500; // Log progress every 500 ms 372 | 373 | // get progress in percentage 374 | const getPercentage = (done, total) => { 375 | if (total <= 0) { 376 | return -1; 377 | } 378 | return Math.round(((done * 100) / total) * 100) / 100; 379 | }; 380 | const checkProgress = () => { 381 | console.log(`${contest._id}::getContestParticipantsData - `, getPercentage(fetchedCount, total)); 382 | }; 383 | const progressInterval = setInterval(checkProgress, logProgressInterval); 384 | 385 | 386 | await Promise.all(promises); 387 | clearInterval(progressInterval); 388 | console.log(`${contest._id}::getContestParticipantsData - total failiures: `, failed.length); 389 | let failedRanks; 390 | const retry = async (limit) => { 391 | console.log("Total failed: ", failedRanks.length, "limit: ", limit); 392 | for (let i = 0; i < failedRanks.length; i += limit) { 393 | let promises = []; 394 | for (let j = 0; j < limit && i + j < failedRanks.length; j++) { 395 | promises.push( 396 | getCurrentRatingHelper(failedRanks[i + j], true) 397 | ); 398 | } 399 | await Promise.all(promises); 400 | console.info( 401 | `users fetched: ${i + limit} (${getPercentage( 402 | Math.min(i + limit, failedRanks.length), 403 | failedRanks.length 404 | )}%)` 405 | ); 406 | } 407 | }; 408 | 409 | const limits = [100, 20, 10, 5]; 410 | for (let i = 0; i < 10; i++) { 411 | limits.push(1); 412 | } 413 | 414 | for (limit of limits) { 415 | if (failed.length === 0) { 416 | break; 417 | } 418 | failedRanks = failed; 419 | failed = []; 420 | await retry(limit); 421 | if (failed.length > 0) { 422 | console.log(`${contest._id}::getContestParticipantsData - total failiures: `, failed.length); 423 | } 424 | } 425 | if (failed.length) { 426 | console.log("Unable to fetch these ranks: ", failed); 427 | return []; 428 | } 429 | await Contest.updateOne( 430 | { _id: contest._id }, 431 | { $set: { users_fetched: true } } 432 | ); 433 | return result; 434 | } catch (err) { 435 | console.error(err); 436 | return []; 437 | } 438 | }; 439 | 440 | const updateUsers = async (job) => { 441 | try { 442 | let offset = job.data.offset; 443 | let limit = job.data.limit; 444 | const users = await User.find({}, { lastUpdated: 1 }) 445 | .skip(offset) 446 | .limit(limit); 447 | if (!users) { 448 | return; 449 | } 450 | 451 | const rateLimit = job.data.rateLimit; 452 | const total = users.length; 453 | 454 | const failed = []; 455 | let totalSuccess = 0; 456 | const fetchUserHelper = async (user) => { 457 | if (!user) { 458 | return; 459 | } 460 | const [data_region, username] = user._id.split("/"); 461 | const [result, err] = await fetchUserInfo(username, data_region); 462 | if (err) { 463 | failed.push(user); 464 | } else { 465 | totalSuccess++; 466 | } 467 | }; 468 | 469 | for (let i = 0; i < total; i += rateLimit) { 470 | let promises = []; 471 | for (let j = 0; j < rateLimit && i + j < total; j++) { 472 | if (users[i + j] && 473 | Date.now() - users[i + j].lastUpdated < 474 | 12 * 60 * 60 * 1000 475 | ) { 476 | totalSuccess++; 477 | continue; 478 | } 479 | promises.push(fetchUserHelper(users[i + j])); 480 | } 481 | await Promise.all(promises); 482 | job.progress((totalSuccess * 100) / total); 483 | } 484 | for (user in failed) { 485 | await fetchUserHelper(user); 486 | job.progress((totalSuccess * 100) / total); 487 | } 488 | if (failed.length > 0) { 489 | console.log(`Failed to fetch ${failed.length} users.`); 490 | } 491 | } catch (err) { 492 | return err; 493 | } 494 | }; 495 | 496 | module.exports = { 497 | getContestParticipantsData, 498 | updateUsers, 499 | BASE_CN_URL, 500 | BASE_URL 501 | } --------------------------------------------------------------------------------