├── .changeset ├── README.md └── config.json ├── .codacy.yaml ├── .eslintignore ├── .eslintrc.cjs ├── .gitattributes ├── .github └── workflows │ ├── docs.yml │ ├── pr_check.yml │ └── release.yml ├── .gitignore ├── .husky ├── post-merge └── pre-commit ├── .prettierrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs.css ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── src ├── _tests │ ├── cache.test.ts │ ├── constants.test.ts │ ├── credential-cn.test.ts │ ├── credential.test.ts │ ├── leetcode-cn.test.ts │ ├── leetcode.test.ts │ ├── mutex.test.ts │ └── utils.test.ts ├── cache.ts ├── constants.ts ├── credential-cn.ts ├── credential.ts ├── env.d.ts ├── fetch.ts ├── graphql │ ├── contest.graphql │ ├── daily.graphql │ ├── leetcode-cn │ │ ├── problem-set.graphql │ │ ├── problem.graphql │ │ ├── question-of-today.graphql │ │ ├── recent-ac-submissions.graphql │ │ ├── submission-detail.graphql │ │ ├── user-contest-ranking.graphql │ │ ├── user-problem-submissions.graphql │ │ ├── user-profile.graphql │ │ ├── user-progress-questions.graphql │ │ └── user-status.graphql │ ├── problem.graphql │ ├── problems.graphql │ ├── profile.graphql │ ├── recent-submissions.graphql │ ├── submission-detail.graphql │ ├── submissions.graphql │ └── whoami.graphql ├── index.ts ├── leetcode-cn.ts ├── leetcode-types.ts ├── leetcode.ts ├── mutex.ts ├── types.ts └── utils.ts ├── tsconfig.json └── tsup.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "jacoblincool/LeetCode-Query" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.codacy.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude_paths: 3 | - "CHANGELOG.md" 4 | - ".changeset/*.md" 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | 4 | # don't lint build output (make sure it's set to your correct build folder name) 5 | lib 6 | 7 | # ignore docs 8 | docs 9 | 10 | # ignore config files 11 | .eslintrc.js 12 | jest.config.js 13 | # tsup.config.ts -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | root: true, 4 | parser: "@typescript-eslint/parser", 5 | plugins: ["@typescript-eslint"], 6 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 7 | rules: { 8 | "@typescript-eslint/explicit-module-boundary-types": [ 9 | "error", 10 | { allowArgumentsExplicitlyTypedAsAny: true }, 11 | ], 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build_and_deploy: 11 | name: Build Docs 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup PNPM 18 | uses: pnpm/action-setup@v3 19 | with: 20 | run_install: true 21 | 22 | - name: TypeDoc Build 23 | run: pnpm run docs 24 | 25 | - name: Deploy 26 | uses: peaceiris/actions-gh-pages@v3 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | publish_dir: "./docs" 30 | user_name: "JacobLinCool" 31 | user_email: "jacoblincool@users.noreply.github.com" 32 | publish_branch: "gh-pages" 33 | -------------------------------------------------------------------------------- /.github/workflows/pr_check.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | pull_request_check: 10 | name: Pull Request Check 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup PNPM 17 | uses: pnpm/action-setup@v3 18 | with: 19 | run_install: true 20 | 21 | - name: Lint 22 | run: pnpm lint 23 | 24 | - name: Test 25 | run: pnpm test 26 | 27 | - name: Build 28 | run: pnpm build 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 30 15 | permissions: 16 | contents: write 17 | issues: write 18 | pull-requests: write 19 | steps: 20 | - name: Checkout Repo 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup PNPM 24 | uses: pnpm/action-setup@v3 25 | with: 26 | run_install: true 27 | 28 | - name: Build 29 | run: pnpm build 30 | 31 | - name: Create Release Pull Request or Publish to NPM 32 | id: changesets 33 | uses: changesets/action@v1 34 | with: 35 | publish: pnpm changeset publish 36 | version: pnpm changeset version 37 | title: Release Packages 38 | commit: bump versions 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | # lib 79 | lib/ 80 | 81 | # docs 82 | docs/ 83 | 84 | **/.DS_Store 85 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | pnpm i 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm i --frozen-lockfile 2 | pnpm build 3 | pnpm lint-staged 4 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | printWidth: 100 3 | tabWidth: 4 4 | useTabs: false 5 | trailingComma: all 6 | semi: true 7 | singleQuote: false 8 | overrides: 9 | - files: "**/*.md" 10 | options: 11 | tabWidth: 2 12 | plugins: 13 | - prettier-plugin-organize-imports 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # leetcode-query 2 | 3 | ## 2.0.0 4 | 5 | ### Major Changes 6 | 7 | - [#105](https://github.com/JacobLinCool/LeetCode-Query/pull/105) [`2a777b1`](https://github.com/JacobLinCool/LeetCode-Query/commit/2a777b1759431d3cdadfa4021e98b9a04e9e15fd) Thanks [@jinzcdev](https://github.com/jinzcdev)! - ## Breaking Changes 8 | 9 | - **`submission` method**: Now uses GraphQL query to fetch submission details, resulting in significant changes to return structure: 10 | - Removed `problem_id` field, replaced by `question.questionId` 11 | - Removed manually calculated percentiles (`runtime_percentile` and `memory_percentile`), replaced by API-provided `runtimePercentile` and `memoryPercentile` values 12 | - Removed `details` field with submission data 13 | - Return structure now directly matches GraphQL response format instead of the previous custom format 14 | 15 | ## New Features 16 | 17 | - Added `submission_detail` GraphQL API query support, fixing API errors for leetcode.com 18 | - Added `user_progress_questions` method to retrieve user progress with filters for leetcode.com 19 | 20 | ## 1.3.0 21 | 22 | ### Minor Changes 23 | 24 | - [#101](https://github.com/JacobLinCool/LeetCode-Query/pull/101) [`c9c774a`](https://github.com/JacobLinCool/LeetCode-Query/commit/c9c774a451d4b3d8421918cd74ae6116f28afec7) Thanks [@jinzcdev](https://github.com/jinzcdev)! - Add APIs for leetcode.cn endpoints 25 | 26 | ## 1.2.3 27 | 28 | ### Patch Changes 29 | 30 | - [`cd8876b`](https://github.com/JacobLinCool/LeetCode-Query/commit/cd8876b0036ce36b7da8cf436b128e016b3ad0b4) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Disable response auto clone on receive-graphql hook 31 | 32 | - [`cd8876b`](https://github.com/JacobLinCool/LeetCode-Query/commit/cd8876b0036ce36b7da8cf436b128e016b3ad0b4) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Allow user to select their own fetch implementation with [@fetch-impl](https://github.com/JacobLinCool/fetch-impl) 33 | 34 | ## 1.2.2 35 | 36 | ### Patch Changes 37 | 38 | - [`bb47140`](https://github.com/JacobLinCool/LeetCode-Query/commit/bb47140ace98ba58da53e853d311fc8ab3f5b42c) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Works with no cookie presented in the response 39 | 40 | ## 1.2.1 41 | 42 | ### Patch Changes 43 | 44 | - [`47ec5d4`](https://github.com/JacobLinCool/LeetCode-Query/commit/47ec5d425daafa15032ddb12b343dffc89fae0c2) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Fix fetcher shortcut 45 | 46 | ## 1.2.0 47 | 48 | ### Minor Changes 49 | 50 | - [`9913aaf`](https://github.com/JacobLinCool/LeetCode-Query/commit/9913aafb01d74ce1b75e2406a6293fbb9014f835) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Allow library users to use their own fetch implementation 51 | 52 | ## 1.1.0 53 | 54 | ### Minor Changes 55 | 56 | - [`c19d509`](https://github.com/JacobLinCool/LeetCode-Query/commit/c19d509bf33be7f26596aae855b9b4998fc2655f) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Support custom headers for GraphQL request 57 | 58 | ## 1.0.1 59 | 60 | ### Patch Changes 61 | 62 | - [`a474021`](https://github.com/JacobLinCool/LeetCode-Query/commit/a474021dfc74aaf9352b98709d23a6ceb933cd63) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Check response status before returning GraphQL data 63 | 64 | ## 1.0.0 65 | 66 | ### Major Changes 67 | 68 | - [#70](https://github.com/JacobLinCool/LeetCode-Query/pull/70) [`b28dd59`](https://github.com/JacobLinCool/LeetCode-Query/commit/b28dd595448835efd7286a3098b57e05f80cb856) Thanks [@JacobLinCool](https://github.com/JacobLinCool)! - Remove dependency on node built-in module 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 JacobLinCool 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LeetCode Query 2 | 3 | The API to get user profiles, submissions, and problems on LeetCode, with highly customizable GraphQL API and Rate Limiter. 4 | 5 | ## Features 6 | 7 | ### Without Authentication 8 | 9 | - [x] Get Public User Profile. 10 | - [x] Get User's Recent Submissions. (Public, Max: 20) 11 | - [x] Get User Contest Records. (thanks to [@laporchen](https://github.com/laporchen)) 12 | - [x] Get All of User's Submissions. (Only for `leetcode.cn` endpoint) 13 | - [x] Get All Problem List, or with filter of difficulty and tags. 14 | - [x] Get Problem Detail. 15 | - [x] Get Daily Challenge. 16 | 17 | ### Authenticated 18 | 19 | - [x] Get All Submissions of The Authenticated User. 20 | - [x] Get Submission Details, including the code and percentiles. 21 | 22 | ### Other 23 | 24 | - [x] Customable GraphQL Query API. 25 | - [x] Customable Rate Limiter. (Default: 20 req / 10 sec) 26 | - [x] Customable Fetcher. 27 | 28 | ## Examples 29 | 30 | ### Get An User's Public Profile 31 | 32 | Includes recent submissions and posts. 33 | 34 | ```typescript 35 | import { LeetCode } from "leetcode-query"; 36 | 37 | const leetcode = new LeetCode(); 38 | const user = await leetcode.user("username"); 39 | 40 | /* 41 | // An Example for leetcode.cn endpoint 42 | import { LeetCodeCN } from "leetcode-query"; 43 | 44 | const leetcodeCN = new LeetCodeCN(); 45 | const user = await leetcodeCN.user("leetcode"); 46 | */ 47 | ``` 48 | 49 | ### Get All Of Your Submissions 50 | 51 | ```typescript 52 | import { LeetCode, Credential } from "leetcode-query"; 53 | 54 | const credential = new Credential(); 55 | await credential.init("YOUR-LEETCODE-SESSION-COOKIE"); 56 | 57 | const leetcode = new LeetCode(credential); 58 | console.log(await leetcode.submissions({ limit: 10, offset: 0 })); 59 | ``` 60 | 61 | ### Use Custom Fetcher 62 | 63 | You can use your own fetcher, for example, fetch through a real browser. 64 | 65 | ```typescript 66 | import { LeetCode, fetcher } from "leetcode-query"; 67 | import { chromium } from "playwright-extra"; 68 | import stealth from "puppeteer-extra-plugin-stealth"; 69 | 70 | // setup browser 71 | const _browser = chromium.use(stealth()).launch(); 72 | const _page = _browser 73 | .then((browser) => browser.newPage()) 74 | .then(async (page) => { 75 | await page.goto("https://leetcode.com"); 76 | return page; 77 | }); 78 | 79 | // use a custom fetcher 80 | fetcher.set(async (...args) => { 81 | const page = await _page; 82 | 83 | const res = await page.evaluate(async (args) => { 84 | const res = await fetch(...args); 85 | return { 86 | body: await res.text(), 87 | status: res.status, 88 | statusText: res.statusText, 89 | headers: Object.fromEntries(res.headers), 90 | }; 91 | }, args); 92 | 93 | return new Response(res.body, res); 94 | }); 95 | 96 | // use as normal 97 | const lc = new LeetCode(); 98 | const daily = await lc.daily(); 99 | console.log(daily); 100 | await _browser.then((browser) => browser.close()); 101 | ``` 102 | 103 | ## Documentation 104 | 105 | Documentation for this package is available on . 106 | 107 | ## Links 108 | 109 | - NPM Package: 110 | - GitHub Repository: 111 | -------------------------------------------------------------------------------- /docs.css: -------------------------------------------------------------------------------- 1 | .hl-1, 2 | .hl-2, 3 | .hl-3, 4 | .hl-4, 5 | .hl-5, 6 | .hl-6, 7 | .hl-7 { 8 | line-height: 1.4; 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leetcode-query", 3 | "version": "2.0.0", 4 | "description": "Get user profiles, submissions, and problems on LeetCode.", 5 | "type": "module", 6 | "types": "lib/index.d.ts", 7 | "exports": { 8 | "import": "./lib/index.js", 9 | "require": "./lib/index.cjs", 10 | "types": "./lib/index.d.ts", 11 | "default": "./lib/index.js" 12 | }, 13 | "files": [ 14 | "lib" 15 | ], 16 | "scripts": { 17 | "prepare": "husky", 18 | "dev": "tsup --watch", 19 | "build": "tsup", 20 | "docs": "typedoc ./src/ --name \"LeetCode Query\" --customCss \"./docs.css\"", 21 | "format": "prettier --write . --ignore-path .gitignore", 22 | "lint": "eslint .", 23 | "test": "vitest --coverage --coverage.include src", 24 | "changeset": "changeset" 25 | }, 26 | "keywords": [ 27 | "leetcode", 28 | "api" 29 | ], 30 | "author": "JacobLinCool (https://github.com/JacobLinCool)", 31 | "license": "MIT", 32 | "dependencies": { 33 | "@fetch-impl/cross-fetch": "^1.0.0", 34 | "@fetch-impl/fetcher": "^1.0.0", 35 | "cross-fetch": "^4.0.0", 36 | "eventemitter3": "^5.0.1" 37 | }, 38 | "devDependencies": { 39 | "@changesets/changelog-github": "^0.5.0", 40 | "@changesets/cli": "^2.27.1", 41 | "@types/node": "20.11.24", 42 | "@typescript-eslint/eslint-plugin": "7.1.0", 43 | "@typescript-eslint/parser": "7.1.0", 44 | "@vitest/coverage-v8": "^1.3.1", 45 | "dotenv": "16.4.5", 46 | "eslint": "8.57.0", 47 | "eslint-config-prettier": "9.1.0", 48 | "husky": "^9.0.11", 49 | "lint-staged": "^15.2.2", 50 | "prettier": "3.2.5", 51 | "prettier-plugin-organize-imports": "^3.2.4", 52 | "rollup-plugin-string": "^3.0.0", 53 | "tsup": "8.0.2", 54 | "typedoc": "0.25.9", 55 | "typescript": "5.3.3", 56 | "vitest": "^1.3.1" 57 | }, 58 | "bugs": { 59 | "url": "https://github.com/JacobLinCool/LeetCode-Query/issues" 60 | }, 61 | "homepage": "https://jacoblincool.github.io/LeetCode-Query/", 62 | "repository": { 63 | "type": "git", 64 | "url": "git+https://github.com/JacobLinCool/LeetCode-Query.git" 65 | }, 66 | "lint-staged": { 67 | "*.ts": [ 68 | "prettier --write", 69 | "eslint --fix" 70 | ], 71 | "*.graphql": [ 72 | "prettier --write" 73 | ] 74 | }, 75 | "packageManager": "pnpm@8.15.4" 76 | } 77 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"] 3 | } 4 | -------------------------------------------------------------------------------- /src/_tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Cache, cache, caches } from "../cache"; 3 | import { sleep } from "../utils"; 4 | 5 | describe("Cache", () => { 6 | describe("default cache", () => { 7 | it("should be a Cache", () => { 8 | expect(cache).toBeInstanceOf(Cache); 9 | }); 10 | 11 | it("should be able to get and set", () => { 12 | cache.set("test", "test"); 13 | expect(cache.get("test")).toBe("test"); 14 | cache.clear(); 15 | }); 16 | 17 | it("should expire after 300ms", async () => { 18 | cache.set("test", "test", 300); 19 | await sleep(300); 20 | expect(cache.get("test")).toBeNull(); 21 | cache.clear(); 22 | }); 23 | 24 | it("should expire immediately", async () => { 25 | cache.set("test", "test", 0); 26 | expect(cache.get("test")).toBeNull(); 27 | cache.clear(); 28 | }); 29 | 30 | it("should be able to remove", () => { 31 | cache.set("test", "test"); 32 | cache.remove("test"); 33 | expect(cache.get("test")).toBeNull(); 34 | cache.clear(); 35 | }); 36 | 37 | it("should be able to clear", () => { 38 | cache.set("test", "test"); 39 | cache.clear(); 40 | expect(cache.get("test")).toBeNull(); 41 | cache.clear(); 42 | }); 43 | 44 | it("should be able to load", () => { 45 | cache.set("test", "test"); 46 | cache.load( 47 | JSON.stringify({ 48 | test: { key: "test", value: "test", expires: Date.now() + 1000 }, 49 | }), 50 | ); 51 | expect(cache.get("test")).toBe("test"); 52 | cache.clear(); 53 | }); 54 | }); 55 | 56 | describe("named caches", () => { 57 | it("should be a Cache", () => { 58 | expect(caches.default).toBeInstanceOf(Cache); 59 | }); 60 | 61 | it("should be able to get and set", () => { 62 | caches.default.set("test", "test"); 63 | expect(caches.default.get("test")).toBe("test"); 64 | caches.default.clear(); 65 | }); 66 | 67 | it("should expire after 300ms", async () => { 68 | caches.default.set("test", "test", 300); 69 | // wait for 305ms to ensure the cache is expired 70 | await sleep(305); 71 | expect(caches.default.get("test")).toBeNull(); 72 | caches.default.clear(); 73 | }); 74 | 75 | it("should be able to remove", () => { 76 | caches.default.set("test", "test"); 77 | caches.default.remove("test"); 78 | expect(caches.default.get("test")).toBeNull(); 79 | caches.default.clear(); 80 | }); 81 | 82 | it("should be able to clear", () => { 83 | caches.default.set("test", "test"); 84 | caches.default.clear(); 85 | expect(caches.default.get("test")).toBeNull(); 86 | caches.default.clear(); 87 | }); 88 | 89 | it("should be able to load", () => { 90 | caches.default.set("test", "test"); 91 | caches.default.load( 92 | JSON.stringify({ 93 | test: { key: "test", value: "test", expires: Date.now() + 1000 }, 94 | }), 95 | ); 96 | expect(caches.default.get("test")).toBe("test"); 97 | caches.default.clear(); 98 | }); 99 | }); 100 | 101 | describe("new cache", () => { 102 | const c = new Cache(); 103 | 104 | it("should be a Cache", () => { 105 | expect(c).toBeInstanceOf(Cache); 106 | }); 107 | 108 | it("should be able to get and set", () => { 109 | c.set("test", "test"); 110 | expect(c.get("test")).toBe("test"); 111 | c.clear(); 112 | }); 113 | 114 | it("should expire after 300ms", async () => { 115 | c.set("test", "test", 300); 116 | await sleep(350); 117 | expect(c.get("test")).toBeNull(); 118 | c.clear(); 119 | }); 120 | 121 | it("should be able to remove", () => { 122 | c.set("test", "test"); 123 | c.remove("test"); 124 | expect(c.get("test")).toBeNull(); 125 | c.clear(); 126 | }); 127 | 128 | it("should be able to clear", () => { 129 | c.set("test", "test"); 130 | c.clear(); 131 | expect(c.get("test")).toBeNull(); 132 | c.clear(); 133 | }); 134 | 135 | it("should be able to load", () => { 136 | c.set("test", "test"); 137 | c.load( 138 | JSON.stringify({ 139 | test: { key: "test", value: "test", expires: Date.now() + 1000 }, 140 | }), 141 | ); 142 | expect(c.get("test")).toBe("test"); 143 | c.clear(); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/_tests/constants.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { BASE_URL, BASE_URL_CN, USER_AGENT } from "../constants"; 3 | 4 | describe("Contants", () => { 5 | it("BASE_URL", () => { 6 | expect(BASE_URL).toBe("https://leetcode.com"); 7 | }); 8 | 9 | it("BASE_URL_CN", () => { 10 | expect(BASE_URL_CN).toBe("https://leetcode.cn"); 11 | }); 12 | 13 | it("USER_AGENT", () => { 14 | expect(USER_AGENT).toBe("Mozilla/5.0 LeetCode API"); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/_tests/credential-cn.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Credential } from "../credential-cn"; 3 | 4 | describe("Credential", () => { 5 | it("should be able to pass session and csrf directly", async () => { 6 | const credential = new Credential({ 7 | session: "test_session", 8 | csrf: "test_csrf", 9 | }); 10 | expect(credential.csrf).toBe("test_csrf"); 11 | expect(credential.session).toBe("test_session"); 12 | }); 13 | 14 | it("should be able to init without session", async () => { 15 | const credential = new Credential(); 16 | await credential.init(); 17 | expect(credential.csrf).toBeDefined(); 18 | expect(credential.session).toBeUndefined(); 19 | }); 20 | 21 | it("should be able to init with session", async () => { 22 | const credential = new Credential(); 23 | await credential.init("test_session"); 24 | expect(credential.csrf).toBeDefined(); 25 | expect(credential.session).toBe("test_session"); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/_tests/credential.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Credential } from "../credential"; 3 | 4 | describe("Credential", () => { 5 | it("should be able to pass session and csrf directly", async () => { 6 | const credential = new Credential({ 7 | session: "test_session", 8 | csrf: "test_csrf", 9 | }); 10 | expect(credential.csrf).toBe("test_csrf"); 11 | expect(credential.session).toBe("test_session"); 12 | }); 13 | 14 | it("should be able to init without session", async () => { 15 | const credential = new Credential(); 16 | await credential.init(); 17 | expect(credential.csrf).toBeDefined(); 18 | expect(credential.session).toBeUndefined(); 19 | }); 20 | 21 | it("should be able to init with session", async () => { 22 | const credential = new Credential(); 23 | await credential.init("test_session"); 24 | expect(credential.csrf).toBeDefined(); 25 | expect(credential.session).toBe("test_session"); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/_tests/leetcode-cn.test.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { beforeAll, describe, expect, it } from "vitest"; 3 | import { Cache } from "../cache"; 4 | import Credential from "../credential-cn"; 5 | import { LeetCodeCN, QuestionStatusEnum } from "../leetcode-cn"; 6 | 7 | describe("LeetCodeCN", { timeout: 15_000 }, () => { 8 | describe("General", () => { 9 | it("should be an instance of LeetCodeCN", () => { 10 | const lc = new LeetCodeCN(); 11 | expect(lc).toBeInstanceOf(LeetCodeCN); 12 | }); 13 | 14 | it("should be able to use user-specified cache", () => { 15 | const lc = new LeetCodeCN(null, new Cache()); 16 | expect(lc).toBeInstanceOf(LeetCodeCN); 17 | expect(lc.cache).toBeInstanceOf(Cache); 18 | }); 19 | }); 20 | 21 | describe("Authenticated", () => { 22 | dotenv.config(); 23 | const credential = new Credential(); 24 | let lc: LeetCodeCN; 25 | 26 | beforeAll(async () => { 27 | await credential.init(process.env["TEST_CN_LEETCODE_SESSION"]); 28 | lc = new LeetCodeCN(credential); 29 | }); 30 | 31 | it.skipIf(!process.env["TEST_CN_LEETCODE_SESSION"])( 32 | "should be able to get own submissions with slug", 33 | async () => { 34 | const submissions = await lc.problem_submissions({ 35 | limit: 30, 36 | offset: 0, 37 | slug: "two-sum", 38 | lang: "cpp", 39 | status: "AC", 40 | }); 41 | expect(Array.isArray(submissions)).toBe(true); 42 | if (submissions.length > 0) { 43 | expect(submissions[0].status).toBe("AC"); 44 | } 45 | }, 46 | ); 47 | 48 | it.skipIf(!process.env["TEST_CN_LEETCODE_SESSION"])( 49 | "should be able to get user progress questions", 50 | async () => { 51 | const progress = await lc.user_progress_questions({ 52 | skip: 0, 53 | limit: 10, 54 | }); 55 | expect(progress).toBeDefined(); 56 | expect(progress.questions.length).toBeLessThanOrEqual(10); 57 | 58 | const progressWithQuestionStatus = await lc.user_progress_questions({ 59 | skip: 0, 60 | limit: 10, 61 | questionStatus: QuestionStatusEnum.ATTEMPTED, 62 | }); 63 | expect(progressWithQuestionStatus).toBeDefined(); 64 | if (progressWithQuestionStatus.questions.length > 0) { 65 | expect(progressWithQuestionStatus.questions[0].questionStatus).toBe( 66 | QuestionStatusEnum.ATTEMPTED, 67 | ); 68 | } 69 | }, 70 | ); 71 | 72 | it.skipIf(!process.env["TEST_CN_LEETCODE_SESSION"])( 73 | "should be able to get user signed in status", 74 | async () => { 75 | const user = await lc.userStatus(); 76 | expect(user.isSignedIn).toBe(true); 77 | }, 78 | ); 79 | 80 | it.skipIf( 81 | !process.env["TEST_CN_LEETCODE_SESSION"] || !process.env["TEST_CN_SUBMISSION_ID"], 82 | )("should be able to get submission detail", async () => { 83 | const submissionId = process.env["TEST_CN_SUBMISSION_ID"]; 84 | if (submissionId) { 85 | const submissionDetail = await lc.submissionDetail(submissionId); 86 | expect(submissionDetail).toBeDefined(); 87 | expect(submissionDetail.id).toBe(submissionId); 88 | expect(submissionDetail.code).toBeDefined(); 89 | } 90 | }); 91 | }); 92 | 93 | describe("Unauthenticated", () => { 94 | const lc = new LeetCodeCN(); 95 | lc.limiter.limit = 100; 96 | lc.limiter.interval = 3; 97 | lc.on("receive-graphql", async (res) => { 98 | await res.clone().json(); 99 | }); 100 | 101 | it("should be able to get user profile", async () => { 102 | const user = await lc.user("LeetCode"); 103 | expect(user.userProfilePublicProfile.profile.realName).toBe("LeetCode"); 104 | }); 105 | 106 | it("should be able to get user's contest info", async () => { 107 | const contest = await lc.user_contest_info("LeetCode"); 108 | expect(contest).toBeDefined(); 109 | }); 110 | 111 | it("should be able to get user's recent submissions", async () => { 112 | const submissions = await lc.recent_submissions("LeetCode"); 113 | expect(Array.isArray(submissions)).toBe(true); 114 | }); 115 | 116 | it("should be able to get problems list", async () => { 117 | const problems = await lc.problems({ limit: 10 }); 118 | expect(problems.questions.length).toBe(10); 119 | }); 120 | 121 | it("should be able to get problem by slug", async () => { 122 | const problem = await lc.problem("two-sum"); 123 | expect(problem.titleSlug).toBe("two-sum"); 124 | }); 125 | 126 | it("should be able to get daily challenge", async () => { 127 | const daily = await lc.daily(); 128 | expect(daily.question).toBeDefined(); 129 | }); 130 | 131 | it("should be able to get user status", async () => { 132 | const user = await lc.userStatus(); 133 | expect(user.isSignedIn).toBe(false); 134 | }); 135 | 136 | it("should throw error when trying to get submissions without slug", async () => { 137 | await expect(lc.problem_submissions({ limit: 30, offset: 0 })).rejects.toThrow( 138 | "LeetCodeCN requires slug parameter for submissions", 139 | ); 140 | }); 141 | 142 | it("should be able to use graphql noj-go", async () => { 143 | const { data } = await lc.graphql( 144 | { 145 | operationName: "data", 146 | variables: { username: "LeetCode" }, 147 | query: ` 148 | query data($username: String!, $year: Int) { 149 | calendar: userCalendar(userSlug: $username, year: $year) { 150 | streak 151 | totalActiveDays 152 | submissionCalendar 153 | } 154 | }`, 155 | }, 156 | "/graphql/noj-go/", 157 | ); 158 | expect(typeof data.calendar.streak).toBe("number"); 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /src/_tests/leetcode.test.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { beforeAll, describe, expect, it } from "vitest"; 3 | import { Cache } from "../cache"; 4 | import { Credential } from "../credential"; 5 | import { LeetCode } from "../leetcode"; 6 | import { QuestionStatusEnum } from "../leetcode-cn"; 7 | 8 | describe("LeetCode", { timeout: 15_000 }, () => { 9 | describe("General", () => { 10 | it("should be an instance of LeetCode", () => { 11 | const lc = new LeetCode(); 12 | expect(lc).toBeInstanceOf(LeetCode); 13 | }); 14 | 15 | it("should be able to use user-specified cache", () => { 16 | const lc = new LeetCode(null, new Cache()); 17 | expect(lc).toBeInstanceOf(LeetCode); 18 | expect(lc.cache).toBeInstanceOf(Cache); 19 | }); 20 | }); 21 | 22 | describe("Unauthenticated", () => { 23 | const lc = new LeetCode(); 24 | lc.limiter.limit = 100; 25 | lc.limiter.interval = 3; 26 | lc.on("receive-graphql", async (res) => { 27 | await res.clone().json(); 28 | }); 29 | 30 | it("should be able to get a user's profile", async () => { 31 | const user = await lc.user("jacoblincool"); 32 | expect(user.matchedUser?.username.toLowerCase()).toBe("jacoblincool"); 33 | }); 34 | 35 | it("should be able to get a user's recent submissions", async () => { 36 | const recent_submissions = await lc.recent_submissions("jacoblincool", 10); 37 | expect(recent_submissions.length).toBe(10); 38 | }); 39 | 40 | it("should be able to use graphql", async () => { 41 | const { data } = await lc.graphql({ 42 | operationName: "getQuestionsCount", 43 | variables: {}, 44 | query: ` 45 | query getQuestionsCount { 46 | allQuestionsCount { 47 | difficulty 48 | count 49 | } 50 | } 51 | `, 52 | }); 53 | expect(data.allQuestionsCount.length).toBe(4); 54 | }); 55 | 56 | it("should be not able to get own submissions", async () => { 57 | await expect(lc.submissions({ limit: 30, offset: 0 })).rejects.toThrow(); 58 | }); 59 | 60 | it("should be able to get own information", async () => { 61 | const user = await lc.whoami(); 62 | expect(user.userId).toBe(null); 63 | expect(user.username).toBe(""); 64 | expect(user.isSignedIn).toBe(false); 65 | }); 66 | 67 | it("should be able to get a user's contest informations", async () => { 68 | const contests = await lc.user_contest_info("lapor"); 69 | expect(contests.userContestRanking.rating).toBeGreaterThan(1500); 70 | expect(contests.userContestRankingHistory.length).toBeGreaterThan(0); 71 | }); 72 | 73 | it("should be able to get problem information", async () => { 74 | const problem = await lc.problem("two-sum"); 75 | expect(problem.title).toBe("Two Sum"); 76 | }); 77 | 78 | it("should be able to get problems", async () => { 79 | const problems = await lc.problems({ filters: { difficulty: "EASY" } }); 80 | expect(problems.total).toBeGreaterThan(500); 81 | expect(problems.questions.length).toBe(100); 82 | }); 83 | 84 | it("should be able to get daily challenge", async () => { 85 | const daily = await lc.daily(); 86 | expect(Date.now() - new Date(daily.date).getTime()).toBeLessThan( 87 | 24 * 60 * 60 * 1000 + 1000, 88 | ); 89 | }); 90 | }); 91 | 92 | describe("Authenticated", () => { 93 | dotenv.config(); 94 | const credential = new Credential(); 95 | let lc: LeetCode; 96 | 97 | beforeAll(async () => { 98 | await credential.init(process.env["TEST_LEETCODE_SESSION"]); 99 | lc = new LeetCode(credential); 100 | }); 101 | 102 | it.skipIf(!process.env["TEST_LEETCODE_SESSION"])( 103 | "should be able to get own submissions", 104 | async () => { 105 | const submissions = await lc.submissions({ limit: 100, offset: 0 }); 106 | expect(submissions.length).greaterThan(0).lessThanOrEqual(100); 107 | }, 108 | ); 109 | 110 | it.skipIf(!process.env["TEST_LEETCODE_SESSION"])( 111 | "should be able to get own information", 112 | async () => { 113 | const user = await lc.whoami(); 114 | expect(typeof user.userId).toBe("number"); 115 | expect(user.username.length).toBeGreaterThan(0); 116 | expect(user.isSignedIn).toBe(true); 117 | }, 118 | ); 119 | 120 | it.skipIf(!process.env["TEST_LEETCODE_SESSION"])( 121 | "should be able to get submission details", 122 | async () => { 123 | const submission = await lc.submission(333333333); 124 | expect(submission.id).toBe(333333333); 125 | expect(submission.memory).toBe(34096000); 126 | expect(submission.runtime).toBe(200); 127 | }, 128 | ); 129 | 130 | it.skipIf(!process.env["TEST_LEETCODE_SESSION"])( 131 | "should be able to get user progress questions", 132 | async () => { 133 | const progress = await lc.user_progress_questions({ 134 | skip: 0, 135 | limit: 10, 136 | }); 137 | expect(progress).toBeDefined(); 138 | expect(progress.questions.length).toBeLessThanOrEqual(10); 139 | 140 | const progressWithQuestionStatus = await lc.user_progress_questions({ 141 | skip: 0, 142 | limit: 10, 143 | questionStatus: QuestionStatusEnum.SOLVED, 144 | }); 145 | expect(progressWithQuestionStatus).toBeDefined(); 146 | if (progressWithQuestionStatus.questions.length > 0) { 147 | expect(progressWithQuestionStatus.questions[0].questionStatus).toBe( 148 | QuestionStatusEnum.SOLVED, 149 | ); 150 | } 151 | }, 152 | ); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/_tests/mutex.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Mutex, RateLimiter } from "../mutex"; 3 | import { sleep } from "../utils"; 4 | 5 | describe("Mutex", () => { 6 | it("should be an instance of Mutex", () => { 7 | const mutex = new Mutex(); 8 | expect(mutex).toBeInstanceOf(Mutex); 9 | }); 10 | 11 | it("should be able to lock and unlock", async () => { 12 | const mutex = new Mutex(); 13 | 14 | const results: number[] = []; 15 | for (let i = 0; i < 10; i++) { 16 | (async () => { 17 | await mutex.lock(); 18 | await sleep(100); 19 | results.push(i); 20 | mutex.unlock(); 21 | })(); 22 | } 23 | 24 | expect(results).toEqual([]); 25 | await sleep(1050); 26 | expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 27 | }); 28 | }); 29 | 30 | describe("RateLimiter", () => { 31 | it("should be an instance of RateLimiter", () => { 32 | const rate_limiter = new RateLimiter(); 33 | expect(rate_limiter).toBeInstanceOf(RateLimiter); 34 | }); 35 | 36 | it("should be able to limit", async () => { 37 | const limiter = new RateLimiter(); 38 | limiter.limit = 4; 39 | limiter.interval = 500; 40 | 41 | const results: number[] = []; 42 | for (let i = 0; i < 10; i++) { 43 | (async () => { 44 | await limiter.lock(); 45 | results.push(i); 46 | await sleep(50); 47 | limiter.unlock(); 48 | })(); 49 | } 50 | 51 | expect(results).toEqual([]); 52 | await sleep(900); 53 | expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); 54 | await sleep(1000); 55 | expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/_tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { parse_cookie, sleep } from "../utils"; 3 | 4 | describe("Utils", () => { 5 | it("parse_cookie", () => { 6 | expect(parse_cookie("a=b; c=d; abc-123=456-def")).toEqual({ 7 | a: "b", 8 | c: "d", 9 | "abc-123": "456-def", 10 | }); 11 | }); 12 | 13 | it("sleep", async () => { 14 | const start = Date.now(); 15 | const returning = await sleep(300, "I am a string"); 16 | expect(Date.now() - start).toBeGreaterThanOrEqual(290); 17 | expect(returning).toBe("I am a string"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { CacheTable } from "./types"; 2 | 3 | /** 4 | * Cache class 5 | */ 6 | class Cache { 7 | private _table: CacheTable = {}; 8 | 9 | /** 10 | * Get an item from the cache. 11 | * @param key The key of the item. 12 | * @returns {any} The item, or null if it doesn't exist. 13 | */ 14 | public get(key: string): unknown { 15 | const item = this._table[key]; 16 | if (item) { 17 | if (item.expires > Date.now()) { 18 | return item.value; 19 | } 20 | this.remove(key); 21 | } 22 | return null; 23 | } 24 | 25 | /** 26 | * Set an item in the cache. 27 | * @param key The key of the item. 28 | * @param value The value of the item. 29 | * @param expires The time in milliseconds until the item expires. 30 | */ 31 | public set(key: string, value: unknown, expires = 60000): void { 32 | this._table[key] = { 33 | key, 34 | value, 35 | expires: expires > 0 ? Date.now() + expires : 0, 36 | }; 37 | } 38 | 39 | /** 40 | * Remove an item from the cache. 41 | * @param key The key of the item. 42 | */ 43 | public remove(key: string): void { 44 | delete this._table[key]; 45 | } 46 | 47 | /** 48 | * Clear the cache. 49 | */ 50 | public clear(): void { 51 | this._table = {}; 52 | } 53 | 54 | /** 55 | * Load the cache from a JSON string. 56 | * @param json A {@link CacheTable}-like JSON string. 57 | */ 58 | public load(json: string): void { 59 | this._table = JSON.parse(json); 60 | } 61 | } 62 | 63 | const cache = new Cache(); 64 | const caches: { [key: string]: Cache } = { default: cache }; 65 | 66 | export default cache; 67 | export { Cache, cache, caches }; 68 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = "https://leetcode.com"; 2 | export const BASE_URL_CN = "https://leetcode.cn"; 3 | export const USER_AGENT = "Mozilla/5.0 LeetCode API"; 4 | -------------------------------------------------------------------------------- /src/credential-cn.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL_CN, USER_AGENT } from "./constants"; 2 | import fetch from "./fetch"; 3 | import type { ICredential } from "./types"; 4 | import { parse_cookie } from "./utils"; 5 | 6 | async function get_csrf() { 7 | const res = await fetch(`${BASE_URL_CN}/graphql/`, { 8 | method: "POST", 9 | headers: { 10 | "content-type": "application/json", 11 | "user-agent": USER_AGENT, 12 | }, 13 | body: JSON.stringify({ 14 | operationName: "nojGlobalData", 15 | variables: {}, 16 | query: "query nojGlobalData {\n siteRegion\n chinaHost\n websocketUrl\n}\n", 17 | }), 18 | }); 19 | const cookies_raw = res.headers.get("set-cookie"); 20 | if (!cookies_raw) { 21 | return undefined; 22 | } 23 | 24 | const csrf_token = parse_cookie(cookies_raw).csrftoken; 25 | return csrf_token; 26 | } 27 | 28 | class Credential implements ICredential { 29 | /** 30 | * The authentication session. 31 | */ 32 | public session?: string; 33 | 34 | /** 35 | * The csrf token. 36 | */ 37 | public csrf?: string; 38 | 39 | constructor(data?: ICredential) { 40 | if (data) { 41 | this.session = data.session; 42 | this.csrf = data.csrf; 43 | } 44 | } 45 | 46 | /** 47 | * Init the credential with or without leetcode session cookie. 48 | * @param session 49 | * @returns 50 | */ 51 | public async init(session?: string): Promise { 52 | this.csrf = await get_csrf(); 53 | if (session) this.session = session; 54 | return this; 55 | } 56 | } 57 | 58 | export default Credential; 59 | export { Credential }; 60 | -------------------------------------------------------------------------------- /src/credential.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL, USER_AGENT } from "./constants"; 2 | import fetch from "./fetch"; 3 | import type { ICredential } from "./types"; 4 | import { parse_cookie } from "./utils"; 5 | 6 | async function get_csrf() { 7 | const cookies_raw = await fetch(BASE_URL + "/graphql/", { 8 | headers: { 9 | "user-agent": USER_AGENT, 10 | }, 11 | }).then((res) => res.headers.get("set-cookie")); 12 | if (!cookies_raw) { 13 | return undefined; 14 | } 15 | 16 | const csrf_token = parse_cookie(cookies_raw).csrftoken; 17 | return csrf_token; 18 | } 19 | 20 | class Credential implements ICredential { 21 | /** 22 | * The authentication session. 23 | */ 24 | public session?: string; 25 | 26 | /** 27 | * The csrf token. 28 | */ 29 | public csrf?: string; 30 | 31 | constructor(data?: ICredential) { 32 | if (data) { 33 | this.session = data.session; 34 | this.csrf = data.csrf; 35 | } 36 | } 37 | 38 | /** 39 | * Init the credential with or without leetcode session cookie. 40 | * @param session 41 | * @returns 42 | */ 43 | public async init(session?: string): Promise { 44 | this.csrf = await get_csrf(); 45 | if (session) this.session = session; 46 | return this; 47 | } 48 | } 49 | 50 | export default Credential; 51 | export { Credential }; 52 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*?raw" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | import { useCrossFetch } from "@fetch-impl/cross-fetch"; 2 | import { Fetcher } from "@fetch-impl/fetcher"; 3 | 4 | export const fetcher = new Fetcher(); 5 | useCrossFetch(fetcher); 6 | 7 | export const _fetch = (...args: Parameters): ReturnType => 8 | fetcher.fetch(...args); 9 | 10 | export default _fetch; 11 | export { _fetch as fetch }; 12 | -------------------------------------------------------------------------------- /src/graphql/contest.graphql: -------------------------------------------------------------------------------- 1 | query ($username: String!) { 2 | userContestRanking(username: $username) { 3 | attendedContestsCount 4 | rating 5 | globalRanking 6 | totalParticipants 7 | topPercentage 8 | badge { 9 | name 10 | } 11 | } 12 | userContestRankingHistory(username: $username) { 13 | attended 14 | trendDirection 15 | problemsSolved 16 | totalProblems 17 | finishTimeInSeconds 18 | rating 19 | ranking 20 | contest { 21 | title 22 | startTime 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/graphql/daily.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | activeDailyCodingChallengeQuestion { 3 | date 4 | link 5 | question { 6 | questionId 7 | questionFrontendId 8 | boundTopicId 9 | title 10 | titleSlug 11 | content 12 | translatedTitle 13 | translatedContent 14 | isPaidOnly 15 | difficulty 16 | likes 17 | dislikes 18 | isLiked 19 | similarQuestions 20 | exampleTestcases 21 | contributors { 22 | username 23 | profileUrl 24 | avatarUrl 25 | } 26 | topicTags { 27 | name 28 | slug 29 | translatedName 30 | } 31 | companyTagStats 32 | codeSnippets { 33 | lang 34 | langSlug 35 | code 36 | } 37 | stats 38 | hints 39 | solution { 40 | id 41 | canSeeDetail 42 | paidOnly 43 | hasVideoSolution 44 | paidOnlyVideo 45 | } 46 | status 47 | sampleTestCase 48 | metaData 49 | judgerAvailable 50 | judgeType 51 | mysqlSchemas 52 | enableRunCode 53 | enableTestMode 54 | enableDebugger 55 | envInfo 56 | libraryUrl 57 | adminUrl 58 | challengeQuestion { 59 | id 60 | date 61 | incompleteChallengeCount 62 | streakCount 63 | type 64 | } 65 | note 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/problem-set.graphql: -------------------------------------------------------------------------------- 1 | query problemsetQuestionList( 2 | $categorySlug: String 3 | $limit: Int 4 | $skip: Int 5 | $filters: QuestionListFilterInput 6 | ) { 7 | problemsetQuestionList( 8 | categorySlug: $categorySlug 9 | limit: $limit 10 | skip: $skip 11 | filters: $filters 12 | ) { 13 | hasMore 14 | total 15 | questions { 16 | acRate 17 | difficulty 18 | freqBar 19 | frontendQuestionId 20 | isFavor 21 | paidOnly 22 | solutionNum 23 | status 24 | title 25 | titleCn 26 | titleSlug 27 | topicTags { 28 | name 29 | nameTranslated 30 | id 31 | slug 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/problem.graphql: -------------------------------------------------------------------------------- 1 | query ($titleSlug: String!) { 2 | question(titleSlug: $titleSlug) { 3 | questionId 4 | questionFrontendId 5 | boundTopicId 6 | title 7 | titleSlug 8 | content 9 | translatedTitle 10 | translatedContent 11 | isPaidOnly 12 | difficulty 13 | likes 14 | dislikes 15 | isLiked 16 | similarQuestions 17 | exampleTestcases 18 | contributors { 19 | username 20 | profileUrl 21 | avatarUrl 22 | } 23 | topicTags { 24 | name 25 | slug 26 | translatedName 27 | } 28 | companyTagStats 29 | codeSnippets { 30 | lang 31 | langSlug 32 | code 33 | } 34 | stats 35 | hints 36 | solution { 37 | id 38 | canSeeDetail 39 | } 40 | status 41 | sampleTestCase 42 | metaData 43 | judgerAvailable 44 | judgeType 45 | mysqlSchemas 46 | enableRunCode 47 | enableTestMode 48 | libraryUrl 49 | note 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/question-of-today.graphql: -------------------------------------------------------------------------------- 1 | query questionOfToday { 2 | todayRecord { 3 | date 4 | userStatus 5 | question { 6 | questionId 7 | frontendQuestionId: questionFrontendId 8 | difficulty 9 | title 10 | titleCn: translatedTitle 11 | titleSlug 12 | paidOnly: isPaidOnly 13 | freqBar 14 | isFavor 15 | acRate 16 | status 17 | solutionNum 18 | hasVideoSolution 19 | topicTags { 20 | name 21 | nameTranslated: translatedName 22 | id 23 | } 24 | extra { 25 | topCompanyTags { 26 | imgUrl 27 | slug 28 | numSubscribed 29 | } 30 | } 31 | } 32 | lastSubmission { 33 | id 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/recent-ac-submissions.graphql: -------------------------------------------------------------------------------- 1 | query recentAcSubmissions($username: String!) { 2 | recentACSubmissions(userSlug: $username) { 3 | submissionId 4 | submitTime 5 | question { 6 | title 7 | translatedTitle 8 | titleSlug 9 | questionFrontendId 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/submission-detail.graphql: -------------------------------------------------------------------------------- 1 | query submissionDetails($submissionId: ID!) { 2 | submissionDetail(submissionId: $submissionId) { 3 | id 4 | code 5 | timestamp 6 | statusDisplay 7 | isMine 8 | runtimeDisplay: runtime 9 | memoryDisplay: memory 10 | memory: rawMemory 11 | lang 12 | langVerboseName 13 | question { 14 | questionId 15 | titleSlug 16 | hasFrontendPreview 17 | } 18 | user { 19 | realName 20 | userAvatar 21 | userSlug 22 | } 23 | runtimePercentile 24 | memoryPercentile 25 | submissionComment { 26 | flagType 27 | } 28 | passedTestCaseCnt 29 | totalTestCaseCnt 30 | fullCodeOutput 31 | testDescriptions 32 | testInfo 33 | testBodies 34 | stdOutput 35 | ... on GeneralSubmissionNode { 36 | outputDetail { 37 | codeOutput 38 | expectedOutput 39 | input 40 | compileError 41 | runtimeError 42 | lastTestcase 43 | } 44 | } 45 | ... on ContestSubmissionNode { 46 | outputDetail { 47 | codeOutput 48 | expectedOutput 49 | input 50 | compileError 51 | runtimeError 52 | lastTestcase 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/user-contest-ranking.graphql: -------------------------------------------------------------------------------- 1 | query userContestRankingInfo($username: String!) { 2 | userContestRanking(userSlug: $username) { 3 | attendedContestsCount 4 | rating 5 | globalRanking 6 | localRanking 7 | globalTotalParticipants 8 | localTotalParticipants 9 | topPercentage 10 | } 11 | userContestRankingHistory(userSlug: $username) { 12 | attended 13 | totalProblems 14 | trendingDirection 15 | finishTimeInSeconds 16 | rating 17 | score 18 | ranking 19 | contest { 20 | title 21 | titleCn 22 | startTime 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/user-problem-submissions.graphql: -------------------------------------------------------------------------------- 1 | query submissionList( 2 | $offset: Int! 3 | $limit: Int! 4 | $lastKey: String 5 | $questionSlug: String 6 | $lang: String 7 | $status: SubmissionStatusEnum 8 | ) { 9 | submissionList( 10 | offset: $offset 11 | limit: $limit 12 | lastKey: $lastKey 13 | questionSlug: $questionSlug 14 | lang: $lang 15 | status: $status 16 | ) { 17 | lastKey 18 | hasNext 19 | submissions { 20 | id 21 | title 22 | status 23 | statusDisplay 24 | lang 25 | langName: langVerboseName 26 | runtime 27 | timestamp 28 | url 29 | isPending 30 | memory 31 | frontendId 32 | submissionComment { 33 | comment 34 | flagType 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/user-profile.graphql: -------------------------------------------------------------------------------- 1 | query getUserProfile($username: String!) { 2 | userProfileUserQuestionProgress(userSlug: $username) { 3 | numAcceptedQuestions { 4 | count 5 | difficulty 6 | } 7 | numFailedQuestions { 8 | count 9 | difficulty 10 | } 11 | numUntouchedQuestions { 12 | count 13 | difficulty 14 | } 15 | } 16 | userProfilePublicProfile(userSlug: $username) { 17 | haveFollowed 18 | siteRanking 19 | profile { 20 | userSlug 21 | realName 22 | aboutMe 23 | asciiCode 24 | userAvatar 25 | gender 26 | websites 27 | skillTags 28 | ipRegion 29 | birthday 30 | location 31 | useDefaultAvatar 32 | certificationLevel 33 | github 34 | school: schoolV2 { 35 | schoolId 36 | logo 37 | name 38 | } 39 | company: companyV2 { 40 | id 41 | logo 42 | name 43 | } 44 | job 45 | globalLocation { 46 | country 47 | province 48 | city 49 | overseasCity 50 | } 51 | socialAccounts { 52 | provider 53 | profileUrl 54 | } 55 | skillSet { 56 | langLevels { 57 | langName 58 | langVerboseName 59 | level 60 | } 61 | topics { 62 | slug 63 | name 64 | translatedName 65 | } 66 | topicAreaScores { 67 | score 68 | topicArea { 69 | name 70 | slug 71 | } 72 | } 73 | } 74 | } 75 | educationRecordList { 76 | unverifiedOrganizationName 77 | } 78 | occupationRecordList { 79 | unverifiedOrganizationName 80 | jobTitle 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/user-progress-questions.graphql: -------------------------------------------------------------------------------- 1 | query userProgressQuestionList($filters: UserProgressQuestionListInput) { 2 | userProgressQuestionList(filters: $filters) { 3 | totalNum 4 | questions { 5 | translatedTitle 6 | frontendId 7 | title 8 | titleSlug 9 | difficulty 10 | lastSubmittedAt 11 | numSubmitted 12 | questionStatus 13 | lastResult 14 | topicTags { 15 | name 16 | nameTranslated 17 | slug 18 | } 19 | } 20 | } 21 | } 22 | 23 | # UserProgressQuestionListInput: 24 | # { 25 | # "filters": { 26 | # "skip": 10, 27 | # "limit": 10, 28 | # "questionStatus": "SOLVED", // Enums: SOLVED, ATTEMPTED 29 | # "difficulty": [ 30 | # "EASY", 31 | # "MEDIUM", 32 | # "HARD" 33 | # ] 34 | # } 35 | # } 36 | -------------------------------------------------------------------------------- /src/graphql/leetcode-cn/user-status.graphql: -------------------------------------------------------------------------------- 1 | query userStatus { 2 | userStatus { 3 | isSignedIn 4 | isAdmin 5 | isStaff 6 | isSuperuser 7 | isTranslator 8 | isVerified 9 | isPhoneVerified 10 | isWechatVerified 11 | checkedInToday 12 | username 13 | realName 14 | userSlug 15 | avatar 16 | region 17 | permissions 18 | useTranslation 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/graphql/problem.graphql: -------------------------------------------------------------------------------- 1 | query ($titleSlug: String!) { 2 | question(titleSlug: $titleSlug) { 3 | questionId 4 | questionFrontendId 5 | boundTopicId 6 | title 7 | titleSlug 8 | content 9 | translatedTitle 10 | translatedContent 11 | isPaidOnly 12 | difficulty 13 | likes 14 | dislikes 15 | isLiked 16 | similarQuestions 17 | exampleTestcases 18 | contributors { 19 | username 20 | profileUrl 21 | avatarUrl 22 | } 23 | topicTags { 24 | name 25 | slug 26 | translatedName 27 | } 28 | companyTagStats 29 | codeSnippets { 30 | lang 31 | langSlug 32 | code 33 | } 34 | stats 35 | hints 36 | solution { 37 | id 38 | canSeeDetail 39 | paidOnly 40 | hasVideoSolution 41 | paidOnlyVideo 42 | } 43 | status 44 | sampleTestCase 45 | metaData 46 | judgerAvailable 47 | judgeType 48 | mysqlSchemas 49 | enableRunCode 50 | enableTestMode 51 | enableDebugger 52 | envInfo 53 | libraryUrl 54 | adminUrl 55 | challengeQuestion { 56 | id 57 | date 58 | incompleteChallengeCount 59 | streakCount 60 | type 61 | } 62 | note 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/graphql/problems.graphql: -------------------------------------------------------------------------------- 1 | query ($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { 2 | problemsetQuestionList: questionList( 3 | categorySlug: $categorySlug 4 | limit: $limit 5 | skip: $skip 6 | filters: $filters 7 | ) { 8 | total: totalNum 9 | questions: data { 10 | acRate 11 | difficulty 12 | freqBar 13 | questionFrontendId 14 | isFavor 15 | isPaidOnly 16 | status 17 | title 18 | titleSlug 19 | topicTags { 20 | name 21 | id 22 | slug 23 | } 24 | hasSolution 25 | hasVideoSolution 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/graphql/profile.graphql: -------------------------------------------------------------------------------- 1 | query ($username: String!) { 2 | allQuestionsCount { 3 | difficulty 4 | count 5 | } 6 | matchedUser(username: $username) { 7 | username 8 | socialAccounts 9 | githubUrl 10 | contributions { 11 | points 12 | questionCount 13 | testcaseCount 14 | } 15 | profile { 16 | realName 17 | websites 18 | countryName 19 | skillTags 20 | company 21 | school 22 | starRating 23 | aboutMe 24 | userAvatar 25 | reputation 26 | ranking 27 | } 28 | submissionCalendar 29 | submitStats { 30 | acSubmissionNum { 31 | difficulty 32 | count 33 | submissions 34 | } 35 | totalSubmissionNum { 36 | difficulty 37 | count 38 | submissions 39 | } 40 | } 41 | badges { 42 | id 43 | displayName 44 | icon 45 | creationDate 46 | } 47 | upcomingBadges { 48 | name 49 | icon 50 | } 51 | activeBadge { 52 | id 53 | } 54 | } 55 | recentSubmissionList(username: $username, limit: 20) { 56 | title 57 | titleSlug 58 | timestamp 59 | statusDisplay 60 | lang 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/graphql/recent-submissions.graphql: -------------------------------------------------------------------------------- 1 | query ($username: String!, $limit: Int) { 2 | recentSubmissionList(username: $username, limit: $limit) { 3 | title 4 | titleSlug 5 | timestamp 6 | statusDisplay 7 | lang 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/graphql/submission-detail.graphql: -------------------------------------------------------------------------------- 1 | query submissionDetails($id: Int!) { 2 | submissionDetails(submissionId: $id) { 3 | id 4 | runtime 5 | runtimeDisplay 6 | runtimePercentile 7 | runtimeDistribution 8 | memory 9 | memoryDisplay 10 | memoryPercentile 11 | memoryDistribution 12 | code 13 | timestamp 14 | statusCode 15 | user { 16 | username 17 | profile { 18 | realName 19 | userAvatar 20 | } 21 | } 22 | lang { 23 | name 24 | verboseName 25 | } 26 | question { 27 | questionId 28 | titleSlug 29 | hasFrontendPreview 30 | } 31 | notes 32 | flagType 33 | topicTags { 34 | tagId 35 | slug 36 | name 37 | } 38 | runtimeError 39 | compileError 40 | lastTestcase 41 | codeOutput 42 | expectedOutput 43 | totalCorrect 44 | totalTestcases 45 | fullCodeOutput 46 | testDescriptions 47 | testBodies 48 | testInfo 49 | stdOutput 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/graphql/submissions.graphql: -------------------------------------------------------------------------------- 1 | query ($offset: Int!, $limit: Int!, $slug: String) { 2 | submissionList(offset: $offset, limit: $limit, questionSlug: $slug) { 3 | hasNext 4 | submissions { 5 | id 6 | lang 7 | time 8 | timestamp 9 | statusDisplay 10 | runtime 11 | url 12 | isPending 13 | title 14 | memory 15 | titleSlug 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/graphql/whoami.graphql: -------------------------------------------------------------------------------- 1 | query { 2 | userStatus { 3 | userId 4 | username 5 | avatar 6 | isSignedIn 7 | isMockUser 8 | isPremium 9 | isAdmin 10 | isSuperuser 11 | isTranslator 12 | permissions 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import LeetCode from "./leetcode"; 2 | import LeetCodeCN from "./leetcode-cn"; 3 | 4 | export default LeetCode; 5 | export * from "./leetcode-types"; 6 | export * from "./types"; 7 | export { LeetCode, LeetCodeCN }; 8 | 9 | export * from "./cache"; 10 | export * from "./constants"; 11 | export * from "./credential"; 12 | export * from "./fetch"; 13 | export * from "./leetcode"; 14 | export * from "./mutex"; 15 | -------------------------------------------------------------------------------- /src/leetcode-cn.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { Cache, cache as default_cache } from "./cache"; 3 | import { BASE_URL_CN, USER_AGENT } from "./constants"; 4 | import { Credential } from "./credential-cn"; 5 | import fetch from "./fetch"; 6 | import PROBLEM_SET from "./graphql/leetcode-cn/problem-set.graphql?raw"; 7 | import PROBLEM from "./graphql/leetcode-cn/problem.graphql?raw"; 8 | import QUESTION_OF_TODAY from "./graphql/leetcode-cn/question-of-today.graphql?raw"; 9 | import RECENT_AC_SUBMISSIONS from "./graphql/leetcode-cn/recent-ac-submissions.graphql?raw"; 10 | import SUBMISSION_DETAIL from "./graphql/leetcode-cn/submission-detail.graphql?raw"; 11 | import USER_CONTEST from "./graphql/leetcode-cn/user-contest-ranking.graphql?raw"; 12 | import USER_PROBLEM_SUBMISSIONS from "./graphql/leetcode-cn/user-problem-submissions.graphql?raw"; 13 | import USER_PROFILE from "./graphql/leetcode-cn/user-profile.graphql?raw"; 14 | import USER_PROGRESS_QUESTIONS from "./graphql/leetcode-cn/user-progress-questions.graphql?raw"; 15 | import USER_STATUS from "./graphql/leetcode-cn/user-status.graphql?raw"; 16 | import { RateLimiter } from "./mutex"; 17 | import type { LeetCodeGraphQLQuery, LeetCodeGraphQLResponse } from "./types"; 18 | import { parse_cookie } from "./utils"; 19 | 20 | export class LeetCodeCN extends EventEmitter { 21 | /** 22 | * The credential this LeetCodeCN instance is using. 23 | */ 24 | public credential: Credential; 25 | 26 | /** 27 | * The internal cache. 28 | */ 29 | public cache: Cache; 30 | 31 | /** 32 | * Used to ensure the LeetCodeCN instance is initialized. 33 | */ 34 | private initialized: Promise; 35 | 36 | /** 37 | * Rate limiter 38 | */ 39 | public limiter = new RateLimiter(); 40 | 41 | /** 42 | * If a credential is provided, the LeetCodeCN API will be authenticated. Otherwise, it will be anonymous. 43 | * @param credential 44 | * @param cache 45 | */ 46 | constructor(credential: Credential | null = null, cache = default_cache) { 47 | super(); 48 | let initialize: CallableFunction; 49 | this.initialized = new Promise((resolve) => { 50 | initialize = resolve; 51 | }); 52 | 53 | this.cache = cache; 54 | 55 | if (credential) { 56 | this.credential = credential; 57 | setImmediate(() => initialize()); 58 | } else { 59 | this.credential = new Credential(); 60 | this.credential.init().then(() => initialize()); 61 | } 62 | } 63 | 64 | /** 65 | * Get public profile of a user. 66 | * @param username 67 | * @returns 68 | * 69 | * ```javascript 70 | * const leetcode = new LeetCodeCN(); 71 | * const profile = await leetcode.user("leetcode"); 72 | * ``` 73 | */ 74 | public async user(username: string): Promise { 75 | await this.initialized; 76 | const { data } = await this.graphql({ 77 | variables: { username }, 78 | query: USER_PROFILE, 79 | }); 80 | return data; 81 | } 82 | 83 | /** 84 | * Get public contest info of a user. 85 | * @param username 86 | * @returns 87 | * 88 | * ```javascript 89 | * const leetcode = new LeetCodeCN(); 90 | * const profile = await leetcode.user_contest_info("username"); 91 | * ``` 92 | */ 93 | public async user_contest_info(username: string): Promise { 94 | await this.initialized; 95 | const { data } = await this.graphql( 96 | { 97 | operationName: "userContestRankingInfo", 98 | variables: { username }, 99 | query: USER_CONTEST, 100 | }, 101 | "/graphql/noj-go/", 102 | ); 103 | return data as UserContestInfo; 104 | } 105 | 106 | /** 107 | * Get recent submissions of a user. (max: 20 submissions) 108 | * @param username 109 | * @param limit 110 | * @returns 111 | * 112 | * ```javascript 113 | * const leetcode = new LeetCodeCN(); 114 | * const submissions = await leetcode.recent_submissions("username"); 115 | * ``` 116 | */ 117 | public async recent_submissions(username: string): Promise { 118 | await this.initialized; 119 | const { data } = await this.graphql( 120 | { 121 | variables: { username }, 122 | query: RECENT_AC_SUBMISSIONS, 123 | }, 124 | "/graphql/noj-go/", 125 | ); 126 | return (data.recentACSubmissions as RecentSubmission[]) || []; 127 | } 128 | 129 | /** 130 | * Get submissions of a problem. 131 | * @param limit The number of submissions to get. Default is 20. 132 | * @param offset The offset of the submissions to get. Default is 0. 133 | * @param slug The slug of the problem. Required. 134 | * @param param0 135 | * @returns 136 | */ 137 | public async problem_submissions({ 138 | limit = 20, 139 | offset = 0, 140 | slug, 141 | lang, 142 | status, 143 | }: { 144 | limit?: number; 145 | offset?: number; 146 | slug?: string; 147 | lang?: string; 148 | status?: string; 149 | } = {}): Promise { 150 | await this.initialized; 151 | 152 | if (!slug) { 153 | throw new Error("LeetCodeCN requires slug parameter for submissions"); 154 | } 155 | 156 | const submissions: Submission[] = []; 157 | const set = new Set(); 158 | 159 | let cursor = offset; 160 | while (submissions.length < limit) { 161 | const { data } = await this.graphql({ 162 | variables: { 163 | offset: cursor, 164 | limit: limit - submissions.length > 20 ? 20 : limit - submissions.length, 165 | questionSlug: slug, 166 | lang, 167 | status, 168 | }, 169 | query: USER_PROBLEM_SUBMISSIONS, 170 | }); 171 | 172 | for (const submission of data.submissionList.submissions) { 173 | submission.id = parseInt(submission.id, 10); 174 | submission.timestamp = parseInt(submission.timestamp, 10) * 1000; 175 | submission.isPending = submission.isPending !== "Not Pending"; 176 | submission.runtime = parseInt(submission.runtime, 10) || 0; 177 | submission.memory = parseFloat(submission.memory) || 0; 178 | 179 | if (set.has(submission.id)) { 180 | continue; 181 | } 182 | 183 | set.add(submission.id); 184 | submissions.push(submission); 185 | } 186 | 187 | if (!data.submissionList.hasNext) { 188 | break; 189 | } 190 | 191 | cursor += 20; 192 | } 193 | 194 | return submissions; 195 | } 196 | 197 | /** 198 | * Get user progress questions. Need to be authenticated. 199 | * @returns 200 | */ 201 | public async user_progress_questions( 202 | filters: UserProgressQuestionListInput, 203 | ): Promise { 204 | await this.initialized; 205 | const { data } = await this.graphql({ 206 | variables: { filters: filters }, 207 | query: USER_PROGRESS_QUESTIONS, 208 | }); 209 | return data.userProgressQuestionList as UserProgressQuestionList; 210 | } 211 | 212 | /** 213 | * Get a list of problems by tags and difficulty. 214 | * @param option 215 | * @param option.category 216 | * @param option.offset 217 | * @param option.limit 218 | * @param option.filters 219 | * @returns 220 | */ 221 | public async problems({ 222 | category = "", 223 | offset = 0, 224 | limit = 100, 225 | filters = {}, 226 | }: { 227 | category?: string; 228 | offset?: number; 229 | limit?: number; 230 | filters?: { 231 | difficulty?: "EASY" | "MEDIUM" | "HARD"; 232 | tags?: string[]; 233 | }; 234 | } = {}): Promise { 235 | await this.initialized; 236 | 237 | const variables = { categorySlug: category, skip: offset, limit, filters }; 238 | 239 | const { data } = await this.graphql({ 240 | variables, 241 | query: PROBLEM_SET, 242 | }); 243 | 244 | return data.problemsetQuestionList as ProblemList; 245 | } 246 | 247 | /** 248 | * Get information of a problem by its slug. 249 | * @param slug Problem slug 250 | * @returns 251 | * 252 | * ```javascript 253 | * const leetcode = new LeetCodeCN(); 254 | * const problem = await leetcode.problem("two-sum"); 255 | * ``` 256 | */ 257 | public async problem(slug: string): Promise { 258 | await this.initialized; 259 | const { data } = await this.graphql({ 260 | variables: { titleSlug: slug.toLowerCase().replace(/\s/g, "-") }, 261 | query: PROBLEM, 262 | }); 263 | 264 | return data.question as Problem; 265 | } 266 | 267 | /** 268 | * Get daily challenge. 269 | * @returns 270 | * 271 | * @example 272 | * ```javascript 273 | * const leetcode = new LeetCodeCN(); 274 | * const daily = await leetcode.daily(); 275 | * ``` 276 | */ 277 | public async daily(): Promise { 278 | await this.initialized; 279 | const { data } = await this.graphql({ 280 | query: QUESTION_OF_TODAY, 281 | }); 282 | 283 | return data.todayRecord[0] as DailyChallenge; 284 | } 285 | 286 | /** 287 | * Check the status information of the current user. 288 | * @returns User status information including login state and permissions 289 | */ 290 | public async userStatus(): Promise { 291 | await this.initialized; 292 | const { data } = await this.graphql({ 293 | query: USER_STATUS, 294 | }); 295 | 296 | return data.userStatus as UserStatus; 297 | } 298 | 299 | /** 300 | * Get detailed information about a submission. 301 | * @param submissionId The ID of the submission 302 | * @returns Detailed information about the submission 303 | * 304 | * ```javascript 305 | * const leetcode = new LeetCodeCN(); 306 | * const detail = await leetcode.submissionDetail("123456789"); 307 | * ``` 308 | */ 309 | public async submissionDetail(submissionId: string): Promise { 310 | await this.initialized; 311 | const { data } = await this.graphql({ 312 | variables: { submissionId }, 313 | query: SUBMISSION_DETAIL, 314 | }); 315 | return data.submissionDetail as SubmissionDetail; 316 | } 317 | 318 | /** 319 | * Use GraphQL to query LeetCodeCN API. 320 | * @param query 321 | * @param endpoint Maybe you want to use `/graphql/noj-go/` instead of `/graphql/`. 322 | * @returns 323 | */ 324 | public async graphql( 325 | query: LeetCodeGraphQLQuery, 326 | endpoint = "/graphql/", 327 | ): Promise { 328 | await this.initialized; 329 | 330 | try { 331 | await this.limiter.lock(); 332 | const BASE = BASE_URL_CN; 333 | const res = await fetch(`${BASE}${endpoint}`, { 334 | method: "POST", 335 | headers: { 336 | "content-type": "application/json", 337 | origin: BASE, 338 | referer: BASE, 339 | cookie: `csrftoken=${this.credential.csrf || ""}; LEETCODE_SESSION=${ 340 | this.credential.session || "" 341 | };`, 342 | "x-csrftoken": this.credential.csrf || "", 343 | "user-agent": USER_AGENT, 344 | ...query.headers, 345 | }, 346 | body: JSON.stringify(query), 347 | }); 348 | if (!res.ok) { 349 | throw new Error(`HTTP ${res.status} ${res.statusText}: ${await res.text()}`); 350 | } 351 | 352 | this.emit("receive-graphql", res); 353 | 354 | if (res.headers.has("set-cookie")) { 355 | const cookies = parse_cookie(res.headers.get("set-cookie") || ""); 356 | 357 | if (cookies["csrftoken"]) { 358 | this.credential.csrf = cookies["csrftoken"]; 359 | this.emit("update-csrf", this.credential); 360 | } 361 | } 362 | 363 | this.limiter.unlock(); 364 | return res.json() as Promise; 365 | } catch (err) { 366 | this.limiter.unlock(); 367 | throw err; 368 | } 369 | } 370 | 371 | emit(event: "receive-graphql", res: Response): boolean; 372 | emit(event: "update-csrf", credential: Credential): boolean; 373 | emit(event: string, ...args: unknown[]): boolean; 374 | emit(event: string, ...args: unknown[]): boolean { 375 | return super.emit(event, ...args); 376 | } 377 | 378 | on(event: "receive-graphql", listener: (res: Response) => void): this; 379 | on(event: "update-csrf", listener: (credential: Credential) => void): this; 380 | on(event: string, listener: (...args: unknown[]) => void): this; 381 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 382 | on(event: string, listener: (...args: any[]) => void): this { 383 | return super.on(event, listener); 384 | } 385 | 386 | once(event: "receive-graphql", listener: (res: Response) => void): this; 387 | once(event: "update-csrf", listener: (credential: Credential) => void): this; 388 | once(event: string, listener: (...args: unknown[]) => void): this; 389 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 390 | once(event: string, listener: (...args: any[]) => void): this { 391 | return super.once(event, listener); 392 | } 393 | } 394 | 395 | export default LeetCodeCN; 396 | 397 | export interface UserProfile { 398 | userProfileUserQuestionProgress: { 399 | numAcceptedQuestions: Array<{ 400 | count: number; 401 | difficulty: string; 402 | }>; 403 | numFailedQuestions: Array<{ 404 | count: number; 405 | difficulty: string; 406 | }>; 407 | numUntouchedQuestions: Array<{ 408 | count: number; 409 | difficulty: string; 410 | }>; 411 | }; 412 | userProfilePublicProfile: { 413 | haveFollowed: boolean; 414 | siteRanking: number; 415 | profile: { 416 | userSlug: string; 417 | realName: string; 418 | aboutMe: string; 419 | asciiCode: string; 420 | userAvatar: string; 421 | gender: string; 422 | websites: string[]; 423 | skillTags: string[]; 424 | ipRegion: string; 425 | birthday: string; 426 | location: string; 427 | useDefaultAvatar: boolean; 428 | certificationLevel: string; 429 | github: string; 430 | school: { 431 | schoolId: string; 432 | logo: string; 433 | name: string; 434 | }; 435 | company: { 436 | id: string; 437 | logo: string; 438 | name: string; 439 | }; 440 | job: string; 441 | globalLocation: { 442 | country: string; 443 | province: string; 444 | city: string; 445 | overseasCity: string; 446 | }; 447 | socialAccounts: Array<{ 448 | provider: string; 449 | profileUrl: string; 450 | }>; 451 | skillSet: { 452 | langLevels: Array<{ 453 | langName: string; 454 | langVerboseName: string; 455 | level: number; 456 | }>; 457 | topics: Array<{ 458 | slug: string; 459 | name: string; 460 | translatedName: string; 461 | }>; 462 | topicAreaScores: Array<{ 463 | score: number; 464 | topicArea: { 465 | name: string; 466 | slug: string; 467 | }; 468 | }>; 469 | }; 470 | }; 471 | educationRecordList: Array<{ 472 | unverifiedOrganizationName: string; 473 | }>; 474 | occupationRecordList: Array<{ 475 | unverifiedOrganizationName: string; 476 | jobTitle: string; 477 | }>; 478 | }; 479 | } 480 | 481 | export interface UserContestInfo { 482 | userContestRanking: { 483 | attendedContestsCount: number; 484 | rating: number; 485 | globalRanking: number; 486 | localRanking: number; 487 | globalTotalParticipants: number; 488 | localTotalParticipants: number; 489 | topPercentage: number; 490 | }; 491 | userContestRankingHistory: Array<{ 492 | attended: boolean; 493 | totalProblems: number; 494 | trendingDirection: string; 495 | finishTimeInSeconds: number; 496 | rating: number; 497 | score: number; 498 | ranking: number; 499 | contest: { 500 | title: string; 501 | titleCn: string; 502 | startTime: number; 503 | }; 504 | }>; 505 | } 506 | 507 | export interface RecentSubmission { 508 | submissionId: string; 509 | submitTime: number; 510 | question: { 511 | title: string; 512 | translatedTitle: string; 513 | titleSlug: string; 514 | questionFrontendId: string; 515 | }; 516 | } 517 | 518 | export interface Submission { 519 | id: number; 520 | title: string; 521 | status: string; 522 | statusDisplay: string; 523 | lang: string; 524 | langName: string; 525 | runtime: number; 526 | timestamp: number; 527 | url: string; 528 | isPending: boolean; 529 | memory: number; 530 | frontendId: string; 531 | submissionComment: { 532 | comment: string; 533 | flagType: string; 534 | }; 535 | } 536 | 537 | export interface ProblemList { 538 | hasMore: boolean; 539 | total: number; 540 | questions: Array<{ 541 | acRate: number; 542 | difficulty: string; 543 | freqBar: number; 544 | frontendQuestionId: string; 545 | isFavor: boolean; 546 | paidOnly: boolean; 547 | solutionNum: number; 548 | status: string; 549 | title: string; 550 | titleCn: string; 551 | titleSlug: string; 552 | topicTags: Array<{ 553 | name: string; 554 | nameTranslated: string; 555 | id: string; 556 | slug: string; 557 | }>; 558 | }>; 559 | } 560 | 561 | export interface Problem { 562 | questionId: string; 563 | questionFrontendId: string; 564 | boundTopicId: string; 565 | title: string; 566 | titleSlug: string; 567 | content: string; 568 | translatedTitle: string; 569 | translatedContent: string; 570 | isPaidOnly: boolean; 571 | difficulty: string; 572 | likes: number; 573 | dislikes: number; 574 | isLiked: boolean; 575 | similarQuestions: string; 576 | exampleTestcases: string; 577 | contributors: Array<{ 578 | username: string; 579 | profileUrl: string; 580 | avatarUrl: string; 581 | }>; 582 | topicTags: Array<{ 583 | name: string; 584 | slug: string; 585 | translatedName: string; 586 | }>; 587 | companyTagStats: string; 588 | codeSnippets: Array<{ 589 | lang: string; 590 | langSlug: string; 591 | code: string; 592 | }>; 593 | stats: string; 594 | hints: string[]; 595 | solution: { 596 | id: string; 597 | canSeeDetail: boolean; 598 | }; 599 | status: string; 600 | sampleTestCase: string; 601 | metaData: string; 602 | judgerAvailable: boolean; 603 | judgeType: string; 604 | mysqlSchemas: string[]; 605 | enableRunCode: boolean; 606 | enableTestMode: boolean; 607 | libraryUrl: string; 608 | note: string; 609 | } 610 | 611 | export interface DailyChallenge { 612 | date: string; 613 | userStatus: string; 614 | question: { 615 | questionId: string; 616 | frontendQuestionId: string; 617 | difficulty: string; 618 | title: string; 619 | titleCn: string; 620 | titleSlug: string; 621 | paidOnly: boolean; 622 | freqBar: number; 623 | isFavor: boolean; 624 | acRate: number; 625 | status: string; 626 | solutionNum: number; 627 | hasVideoSolution: boolean; 628 | topicTags: Array<{ 629 | name: string; 630 | nameTranslated: string; 631 | id: string; 632 | }>; 633 | extra: { 634 | topCompanyTags: Array<{ 635 | imgUrl: string; 636 | slug: string; 637 | numSubscribed: number; 638 | }>; 639 | }; 640 | }; 641 | lastSubmission: { 642 | id: string; 643 | }; 644 | } 645 | 646 | export interface UserStatus { 647 | isSignedIn: boolean; 648 | isAdmin: boolean; 649 | isStaff: boolean; 650 | isSuperuser: boolean; 651 | isTranslator: boolean; 652 | isVerified: boolean; 653 | isPhoneVerified: boolean; 654 | isWechatVerified: boolean; 655 | checkedInToday: boolean; 656 | username: string; 657 | realName: string; 658 | userSlug: string; 659 | avatar: string; 660 | region: string; 661 | permissions: string[]; 662 | useTranslation: boolean; 663 | } 664 | 665 | export interface UserProgressQuestionList { 666 | totalNum: number; 667 | questions: Array<{ 668 | translatedTitle: string; 669 | frontendId: string; 670 | title: string; 671 | titleSlug: string; 672 | difficulty: string; 673 | lastSubmittedAt: string; 674 | numSubmitted: number; 675 | questionStatus: string; 676 | lastResult: string; 677 | topicTags: Array<{ 678 | name: string; 679 | nameTranslated: string; 680 | slug: string; 681 | }>; 682 | }>; 683 | } 684 | 685 | export interface UserProgressQuestionListInput { 686 | difficulty?: Array; 687 | questionStatus?: QuestionStatusEnum; 688 | skip: number; 689 | limit: number; 690 | } 691 | 692 | export enum DifficultyEnum { 693 | EASY = "EASY", 694 | MEDIUM = "MEDIUM", 695 | HARD = "HARD", 696 | } 697 | 698 | export enum QuestionStatusEnum { 699 | ATTEMPTED = "ATTEMPTED", 700 | SOLVED = "SOLVED", 701 | } 702 | 703 | export interface SubmissionQuestion { 704 | questionId: string; 705 | titleSlug: string; 706 | hasFrontendPreview: boolean; 707 | } 708 | 709 | export interface SubmissionUser { 710 | realName: string; 711 | userAvatar: string; 712 | userSlug: string; 713 | } 714 | 715 | export interface OutputDetail { 716 | codeOutput: string; 717 | expectedOutput: string; 718 | input: string; 719 | compileError: string; 720 | runtimeError: string; 721 | lastTestcase: string; 722 | } 723 | 724 | export interface SubmissionDetail { 725 | id: string; 726 | code: string; 727 | timestamp: number; 728 | statusDisplay: string; 729 | isMine: boolean; 730 | runtimeDisplay: string; 731 | memoryDisplay: string; 732 | memory: string; 733 | lang: string; 734 | langVerboseName: string; 735 | question: SubmissionQuestion; 736 | user: SubmissionUser; 737 | runtimePercentile: number; 738 | memoryPercentile: number; 739 | submissionComment: null | { 740 | flagType: string; 741 | }; 742 | passedTestCaseCnt: number; 743 | totalTestCaseCnt: number; 744 | fullCodeOutput: null | string; 745 | testDescriptions: null | string; 746 | testInfo: null | string; 747 | testBodies: null | string; 748 | stdOutput: string; 749 | outputDetail: OutputDetail; 750 | } 751 | -------------------------------------------------------------------------------- /src/leetcode-types.ts: -------------------------------------------------------------------------------- 1 | ////////////////////////////////////////////////////////////////////////////// 2 | // GraphQL 3 | export interface AllQuestionsCount { 4 | difficulty: string; 5 | count: number; 6 | } 7 | 8 | export interface Contributions { 9 | points: number; 10 | questionCount: number; 11 | testcaseCount: number; 12 | } 13 | 14 | export interface Profile { 15 | realName: string; 16 | websites: string[]; 17 | countryName: string | null; 18 | skillTags: string[]; 19 | company: string | null; 20 | school: string | null; 21 | starRating: number; 22 | aboutMe: string; 23 | userAvatar: string; 24 | reputation: number; 25 | ranking: number; 26 | } 27 | 28 | export interface AcSubmissionNum { 29 | difficulty: string; 30 | count: number; 31 | submissions: number; 32 | } 33 | 34 | export interface TotalSubmissionNum { 35 | difficulty: string; 36 | count: number; 37 | submissions: number; 38 | } 39 | 40 | export interface SubmitStats { 41 | acSubmissionNum: AcSubmissionNum[]; 42 | totalSubmissionNum: TotalSubmissionNum[]; 43 | } 44 | 45 | export interface Badge { 46 | id: string; 47 | displayName: string; 48 | icon: string; 49 | creationDate?: string; 50 | } 51 | 52 | export interface MatchedUser { 53 | username: string; 54 | socialAccounts: unknown; 55 | githubUrl: null; 56 | contributions: Contributions; 57 | profile: Profile; 58 | submissionCalendar: string; 59 | submitStats: SubmitStats; 60 | badges: Badge[]; 61 | upcomingBadges: Badge[]; 62 | activeBadge: Badge | null; 63 | } 64 | 65 | export interface RecentSubmission { 66 | title: string; 67 | titleSlug: string; 68 | timestamp: string; 69 | statusDisplay: string; 70 | lang: string; 71 | } 72 | 73 | export interface UserProfile { 74 | allQuestionsCount: AllQuestionsCount[]; 75 | matchedUser: MatchedUser | null; 76 | recentSubmissionList: RecentSubmission[] | null; 77 | } 78 | 79 | export interface Contest { 80 | title: string; 81 | startTime: number; 82 | } 83 | 84 | export interface ContestInfo { 85 | attended: boolean; 86 | trendDirection: string; 87 | problemsSolved: number; 88 | totalProblems: number; 89 | finishTimeInSeconds: number; 90 | rating: number; 91 | ranking: number; 92 | contest: Contest; 93 | } 94 | export interface ContestRanking { 95 | attendedContestsCount: number; 96 | rating: number; 97 | globalRanking: number; 98 | totalParticipants: number; 99 | topPercentage: number; 100 | badge: null | { 101 | name: string; 102 | }; 103 | } 104 | 105 | export interface UserContestInfo { 106 | userContestRanking: ContestRanking; 107 | userContestRankingHistory: ContestInfo[]; 108 | } 109 | 110 | export interface TopicTag { 111 | name: string; 112 | slug: string; 113 | translatedName: string | null; 114 | } 115 | 116 | export interface CodeSnippet { 117 | lang: string; 118 | langSlug: string; 119 | code: string; 120 | } 121 | 122 | export interface OfficialSolution { 123 | id: string; 124 | canSeeDetail: boolean; 125 | paidOnly: boolean; 126 | hasVideoSolution: boolean; 127 | paidOnlyVideo: boolean; 128 | } 129 | 130 | export interface ChallengeQuestion { 131 | id: string; 132 | date: string; 133 | incompleteChallengeCount: number; 134 | streakCount: number; 135 | type: string; 136 | } 137 | 138 | export type ProblemDifficulty = "Easy" | "Medium" | "Hard"; 139 | 140 | export interface Problem { 141 | questionId: string; 142 | questionFrontendId: string; 143 | boundTopicId: unknown; 144 | title: string; 145 | titleSlug: string; 146 | content: string; 147 | translatedTitle: string | null; 148 | translatedContent: string | null; 149 | isPaidOnly: boolean; 150 | difficulty: ProblemDifficulty; 151 | likes: number; 152 | dislikes: number; 153 | isLiked: boolean | null; 154 | similarQuestions: string; 155 | exampleTestcases: string; 156 | contributors: unknown[]; 157 | topicTags: TopicTag[]; 158 | companyTagStats: unknown; 159 | codeSnippets: CodeSnippet[]; 160 | stats: string; 161 | hints: string[]; 162 | solution: OfficialSolution; 163 | status: unknown; 164 | sampleTestCase: string; 165 | metaData: string; 166 | judgerAvailable: boolean; 167 | judgeType: string; 168 | mysqlSchemas: unknown[]; 169 | enableRunCode: boolean; 170 | enableTestMode: boolean; 171 | enableDebugger: boolean; 172 | envInfo: string; 173 | libraryUrl: string | null; 174 | adminUrl: string | null; 175 | challengeQuestion: ChallengeQuestion; 176 | /** null if not logged in */ 177 | note: string | null; 178 | } 179 | 180 | ////////////////////////////////////////////////////////////////////////////// 181 | // API 182 | export type SubmissionStatus = 183 | | "Accepted" 184 | | "Wrong Answer" 185 | | "Time Limit Exceeded" 186 | | "Memory Limit Exceeded" 187 | | "Output Limit Exceeded" 188 | | "Compile Error" 189 | | "Runtime Error"; 190 | 191 | export interface Submission { 192 | /** 193 | * Submission ID 194 | */ 195 | id: number; 196 | 197 | /** 198 | * Submission Language 199 | */ 200 | lang: string; 201 | 202 | /** 203 | * Submission Time (Relative) 204 | */ 205 | time: string; 206 | 207 | /** 208 | * Submission Time (Unix Time in Seconds) 209 | */ 210 | timestamp: number; 211 | 212 | /** 213 | * Submission Status 214 | */ 215 | statusDisplay: SubmissionStatus; 216 | 217 | /** 218 | * Submission Runtime, in milliseconds 219 | */ 220 | runtime: number; 221 | 222 | /** 223 | * URL path of the submission without domain 224 | */ 225 | url: string; 226 | 227 | /** 228 | * true if the submission is still pending 229 | */ 230 | isPending: boolean; 231 | 232 | /** 233 | * Title of the problem 234 | */ 235 | title: string; 236 | 237 | /** 238 | * Submission Memory Usage, in MB 239 | */ 240 | memory: number; 241 | 242 | /** 243 | * Problem Slug 244 | */ 245 | titleSlug: string; 246 | } 247 | 248 | export interface Whoami { 249 | userId: number | null; 250 | username: string; 251 | avatar: string | null; 252 | isSignedIn: boolean; 253 | isMockUser: boolean; 254 | isPremium: boolean | null; 255 | isAdmin: boolean; 256 | isSuperuser: boolean; 257 | isTranslator: boolean; 258 | permissions: string[]; 259 | } 260 | 261 | export interface SubmissionDetail { 262 | id: number; 263 | runtime: number; 264 | runtimeDisplay: string; 265 | runtimePercentile: number; 266 | runtimeDistribution: string; 267 | memory: number; 268 | memoryDisplay: string; 269 | memoryPercentile: number; 270 | memoryDistribution: string; 271 | code: string; 272 | timestamp: number; 273 | statusCode: number; 274 | user: { 275 | username: string; 276 | profile: { 277 | realName: string; 278 | userAvatar: string; 279 | }; 280 | }; 281 | lang: { 282 | name: string; 283 | verboseName: string; 284 | }; 285 | question: { 286 | questionId: string; 287 | titleSlug: string; 288 | hasFrontendPreview: boolean; 289 | }; 290 | notes: string; 291 | flagType: string; 292 | topicTags: string[]; 293 | runtimeError: string | null; 294 | compileError: string | null; 295 | lastTestcase: string; 296 | codeOutput: string; 297 | expectedOutput: string; 298 | totalCorrect: number; 299 | totalTestcases: number; 300 | fullCodeOutput: string | null; 301 | testDescriptions: string | null; 302 | testBodies: string | null; 303 | testInfo: string | null; 304 | stdOutput: string | null; 305 | } 306 | 307 | export interface ProblemList { 308 | total: number; 309 | questions: { 310 | acRate: number; 311 | difficulty: "Easy" | "Medium" | "Hard"; 312 | freqBar: null; 313 | questionFrontendId: string; 314 | isFavor: boolean; 315 | isPaidOnly: boolean; 316 | status: string | null; 317 | title: string; 318 | titleSlug: string; 319 | topicTags: { 320 | name: string; 321 | id: string; 322 | slug: string; 323 | }[]; 324 | hasSolution: boolean; 325 | hasVideoSolution: boolean; 326 | }[]; 327 | } 328 | 329 | export interface DailyChallenge { 330 | date: string; 331 | link: string; 332 | question: Problem; 333 | } 334 | -------------------------------------------------------------------------------- /src/leetcode.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | import { Cache, cache as default_cache } from "./cache"; 3 | import { BASE_URL, USER_AGENT } from "./constants"; 4 | import { Credential } from "./credential"; 5 | import fetch from "./fetch"; 6 | import CONTEST from "./graphql/contest.graphql?raw"; 7 | import DAILY from "./graphql/daily.graphql?raw"; 8 | import USER_PROGRESS_QUESTIONS from "./graphql/leetcode-cn/user-progress-questions.graphql?raw"; 9 | import PROBLEM from "./graphql/problem.graphql?raw"; 10 | import PROBLEMS from "./graphql/problems.graphql?raw"; 11 | import PROFILE from "./graphql/profile.graphql?raw"; 12 | import RECENT_SUBMISSIONS from "./graphql/recent-submissions.graphql?raw"; 13 | import SUBMISSION_DETAIL from "./graphql/submission-detail.graphql?raw"; 14 | import SUBMISSIONS from "./graphql/submissions.graphql?raw"; 15 | import WHOAMI from "./graphql/whoami.graphql?raw"; 16 | import { UserProgressQuestionList, UserProgressQuestionListInput } from "./leetcode-cn"; 17 | import type { 18 | DailyChallenge, 19 | Problem, 20 | ProblemList, 21 | RecentSubmission, 22 | Submission, 23 | SubmissionDetail, 24 | UserContestInfo, 25 | UserProfile, 26 | Whoami, 27 | } from "./leetcode-types"; 28 | import { RateLimiter } from "./mutex"; 29 | import type { LeetCodeGraphQLQuery, LeetCodeGraphQLResponse } from "./types"; 30 | import { parse_cookie } from "./utils"; 31 | 32 | export class LeetCode extends EventEmitter { 33 | /** 34 | * The credential this LeetCode instance is using. 35 | */ 36 | public credential: Credential; 37 | 38 | /** 39 | * The internal cache. 40 | */ 41 | public cache: Cache; 42 | 43 | /** 44 | * Used to ensure the LeetCode instance is initialized. 45 | */ 46 | private initialized: Promise; 47 | 48 | /** 49 | * Rate limiter 50 | */ 51 | public limiter = new RateLimiter(); 52 | 53 | /** 54 | * If a credential is provided, the LeetCode API will be authenticated. Otherwise, it will be anonymous. 55 | * @param credential 56 | * @param cache 57 | */ 58 | constructor(credential: Credential | null = null, cache = default_cache) { 59 | super(); 60 | let initialize: CallableFunction; 61 | this.initialized = new Promise((resolve) => { 62 | initialize = resolve; 63 | }); 64 | 65 | this.cache = cache; 66 | 67 | if (credential) { 68 | this.credential = credential; 69 | setImmediate(() => initialize()); 70 | } else { 71 | this.credential = new Credential(); 72 | this.credential.init().then(() => initialize()); 73 | } 74 | } 75 | 76 | /** 77 | * Get public profile of a user. 78 | * @param username 79 | * @returns 80 | * 81 | * ```javascript 82 | * const leetcode = new LeetCode(); 83 | * const profile = await leetcode.user("jacoblincool"); 84 | * ``` 85 | */ 86 | public async user(username: string): Promise { 87 | await this.initialized; 88 | const { data } = await this.graphql({ 89 | variables: { username }, 90 | query: PROFILE, 91 | }); 92 | return data as UserProfile; 93 | } 94 | 95 | /** 96 | * Get public contest info of a user. 97 | * @param username 98 | * @returns 99 | * 100 | * ```javascript 101 | * const leetcode = new LeetCode(); 102 | * const profile = await leetcode.user_contest_info("jacoblincool"); 103 | * ``` 104 | */ 105 | public async user_contest_info(username: string): Promise { 106 | await this.initialized; 107 | const { data } = await this.graphql({ 108 | variables: { username }, 109 | query: CONTEST, 110 | }); 111 | return data as UserContestInfo; 112 | } 113 | 114 | /** 115 | * Get recent submissions of a user. (max: 20 submissions) 116 | * @param username 117 | * @param limit 118 | * @returns 119 | * 120 | * ```javascript 121 | * const leetcode = new LeetCode(); 122 | * const submissions = await leetcode.recent_submissions("jacoblincool"); 123 | * ``` 124 | */ 125 | public async recent_submissions(username: string, limit = 20): Promise { 126 | await this.initialized; 127 | const { data } = await this.graphql({ 128 | variables: { username, limit }, 129 | query: RECENT_SUBMISSIONS, 130 | }); 131 | return (data.recentSubmissionList as RecentSubmission[]) || []; 132 | } 133 | 134 | /** 135 | * Get submissions of the credential user. Need to be authenticated. 136 | * 137 | * @returns 138 | * 139 | * ```javascript 140 | * const credential = new Credential(); 141 | * await credential.init("SESSION"); 142 | * const leetcode = new LeetCode(credential); 143 | * const submissions = await leetcode.submissions({ limit: 100, offset: 0 }); 144 | * ``` 145 | */ 146 | public async submissions({ 147 | limit = 20, 148 | offset = 0, 149 | slug, 150 | }: { limit?: number; offset?: number; slug?: string } = {}): Promise { 151 | await this.initialized; 152 | 153 | const submissions: Submission[] = []; 154 | const set = new Set(); 155 | 156 | let cursor = offset; 157 | while (submissions.length < limit) { 158 | const { data } = await this.graphql({ 159 | variables: { 160 | offset: cursor, 161 | limit: limit - submissions.length > 20 ? 20 : limit - submissions.length, 162 | slug, 163 | }, 164 | query: SUBMISSIONS, 165 | }); 166 | 167 | for (const submission of data.submissionList.submissions) { 168 | submission.id = parseInt(submission.id, 10); 169 | submission.timestamp = parseInt(submission.timestamp, 10) * 1000; 170 | submission.isPending = submission.isPending !== "Not Pending"; 171 | submission.runtime = parseInt(submission.runtime, 10) || 0; 172 | submission.memory = parseFloat(submission.memory) || 0; 173 | 174 | if (set.has(submission.id)) { 175 | continue; 176 | } 177 | 178 | set.add(submission.id); 179 | submissions.push(submission); 180 | } 181 | 182 | if (!data.submissionList.hasNext) { 183 | break; 184 | } 185 | 186 | cursor += 20; 187 | } 188 | 189 | return submissions; 190 | } 191 | 192 | /** 193 | * Get detail of a submission, including the code and percentiles. 194 | * Need to be authenticated. 195 | * @param id Submission ID 196 | * @returns 197 | */ 198 | public async submission(id: number): Promise { 199 | await this.initialized; 200 | const { data } = await this.graphql({ 201 | variables: { id }, 202 | query: SUBMISSION_DETAIL, 203 | }); 204 | 205 | return data.submissionDetails as SubmissionDetail; 206 | } 207 | 208 | /** 209 | * Get user progress questions. Need to be authenticated. 210 | * @returns 211 | */ 212 | public async user_progress_questions( 213 | filters: UserProgressQuestionListInput, 214 | ): Promise { 215 | await this.initialized; 216 | const { data } = await this.graphql({ 217 | variables: { filters: filters }, 218 | query: USER_PROGRESS_QUESTIONS, 219 | }); 220 | return data.userProgressQuestionList as UserProgressQuestionList; 221 | } 222 | 223 | /** 224 | * Get a list of problems by tags and difficulty. 225 | * @param option 226 | * @param option.category 227 | * @param option.offset 228 | * @param option.limit 229 | * @param option.filters 230 | * @returns 231 | */ 232 | public async problems({ 233 | category = "", 234 | offset = 0, 235 | limit = 100, 236 | filters = {}, 237 | }: { 238 | category?: string; 239 | offset?: number; 240 | limit?: number; 241 | filters?: { 242 | difficulty?: "EASY" | "MEDIUM" | "HARD"; 243 | tags?: string[]; 244 | }; 245 | } = {}): Promise { 246 | await this.initialized; 247 | 248 | const variables = { categorySlug: category, skip: offset, limit, filters }; 249 | 250 | const { data } = await this.graphql({ 251 | variables, 252 | query: PROBLEMS, 253 | }); 254 | 255 | return data.problemsetQuestionList as ProblemList; 256 | } 257 | 258 | /** 259 | * Get information of a problem by its slug. 260 | * @param slug Problem slug 261 | * @returns 262 | * 263 | * ```javascript 264 | * const leetcode = new LeetCode(); 265 | * const problem = await leetcode.problem("two-sum"); 266 | * ``` 267 | */ 268 | public async problem(slug: string): Promise { 269 | await this.initialized; 270 | const { data } = await this.graphql({ 271 | variables: { titleSlug: slug.toLowerCase().replace(/\s/g, "-") }, 272 | query: PROBLEM, 273 | }); 274 | 275 | return data.question as Problem; 276 | } 277 | 278 | /** 279 | * Get daily challenge. 280 | * @returns 281 | * 282 | * @example 283 | * ```javascript 284 | * const leetcode = new LeetCode(); 285 | * const daily = await leetcode.daily(); 286 | * ``` 287 | */ 288 | public async daily(): Promise { 289 | await this.initialized; 290 | const { data } = await this.graphql({ 291 | query: DAILY, 292 | }); 293 | 294 | return data.activeDailyCodingChallengeQuestion as DailyChallenge; 295 | } 296 | 297 | /** 298 | * Check the information of the credential owner. 299 | * @returns 300 | */ 301 | public async whoami(): Promise { 302 | await this.initialized; 303 | const { data } = await this.graphql({ 304 | variables: {}, 305 | query: WHOAMI, 306 | }); 307 | 308 | return data.userStatus as Whoami; 309 | } 310 | 311 | /** 312 | * Use GraphQL to query LeetCode API. 313 | * @param query 314 | * @returns 315 | */ 316 | public async graphql(query: LeetCodeGraphQLQuery): Promise { 317 | await this.initialized; 318 | 319 | try { 320 | await this.limiter.lock(); 321 | 322 | const BASE = BASE_URL; 323 | const res = await fetch(`${BASE}/graphql`, { 324 | method: "POST", 325 | headers: { 326 | "content-type": "application/json", 327 | origin: BASE, 328 | referer: BASE, 329 | cookie: `csrftoken=${this.credential.csrf || ""}; LEETCODE_SESSION=${ 330 | this.credential.session || "" 331 | };`, 332 | "x-csrftoken": this.credential.csrf || "", 333 | "user-agent": USER_AGENT, 334 | ...query.headers, 335 | }, 336 | body: JSON.stringify(query), 337 | }); 338 | if (!res.ok) { 339 | throw new Error(`HTTP ${res.status} ${res.statusText}: ${await res.text()}`); 340 | } 341 | 342 | this.emit("receive-graphql", res); 343 | 344 | if (res.headers.has("set-cookie")) { 345 | const cookies = parse_cookie(res.headers.get("set-cookie") || ""); 346 | 347 | if (cookies["csrftoken"]) { 348 | this.credential.csrf = cookies["csrftoken"]; 349 | this.emit("update-csrf", this.credential); 350 | } 351 | } 352 | 353 | this.limiter.unlock(); 354 | return res.json() as Promise; 355 | } catch (err) { 356 | this.limiter.unlock(); 357 | throw err; 358 | } 359 | } 360 | 361 | emit(event: "receive-graphql", res: Response): boolean; 362 | emit(event: "update-csrf", credential: Credential): boolean; 363 | emit(event: string, ...args: unknown[]): boolean; 364 | emit(event: string, ...args: unknown[]): boolean { 365 | return super.emit(event, ...args); 366 | } 367 | 368 | on(event: "receive-graphql", listener: (res: Response) => void): this; 369 | on(event: "update-csrf", listener: (credential: Credential) => void): this; 370 | on(event: string, listener: (...args: unknown[]) => void): this; 371 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 372 | on(event: string, listener: (...args: any[]) => void): this { 373 | return super.on(event, listener); 374 | } 375 | 376 | once(event: "receive-graphql", listener: (res: Response) => void): this; 377 | once(event: "update-csrf", listener: (credential: Credential) => void): this; 378 | once(event: string, listener: (...args: unknown[]) => void): this; 379 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 380 | once(event: string, listener: (...args: any[]) => void): this { 381 | return super.once(event, listener); 382 | } 383 | } 384 | 385 | export default LeetCode; 386 | -------------------------------------------------------------------------------- /src/mutex.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "eventemitter3"; 2 | 3 | export type Release = (value: void | PromiseLike) => void; 4 | 5 | export class Mutex extends EventEmitter { 6 | protected space: number; 7 | protected used: number; 8 | protected releases: Release[]; 9 | 10 | constructor(space = 1) { 11 | super(); 12 | this.space = space; 13 | this.used = 0; 14 | this.releases = []; 15 | } 16 | 17 | async lock(): Promise { 18 | if (this.used >= this.space) { 19 | const lock = new Promise((r) => this.releases.push(r)); 20 | this.emit("wait", { 21 | lock, 22 | release: this.releases[this.releases.length - 1], 23 | }); 24 | await lock; 25 | } 26 | this.used++; 27 | this.emit("lock"); 28 | 29 | return this.used; 30 | } 31 | 32 | unlock(): number { 33 | if (this.used <= 0) { 34 | return 0; 35 | } 36 | 37 | if (this.releases.length > 0) { 38 | this.releases.shift()?.(); 39 | } 40 | this.used--; 41 | this.emit("unlock"); 42 | 43 | if (this.used <= 0) { 44 | this.emit("all-clear"); 45 | } 46 | 47 | return this.used; 48 | } 49 | 50 | resize(space: number): number { 51 | this.space = space; 52 | 53 | while (this.used < space && this.releases.length > 0) { 54 | this.releases.shift()?.(); 55 | } 56 | 57 | return this.space; 58 | } 59 | 60 | full(): boolean { 61 | return this.used >= this.space; 62 | } 63 | 64 | waiting(): number { 65 | return this.releases.length; 66 | } 67 | 68 | emit(event: "lock" | "unlock" | "all-clear"): boolean; 69 | emit(event: "wait", { lock, release }: { lock: Promise; release: Release }): boolean; 70 | emit(event: string): boolean; 71 | emit(event: string, ...args: unknown[]): boolean { 72 | return super.emit(event, ...args); 73 | } 74 | 75 | on(event: "lock" | "unlock" | "all-clear", listener: () => void): this; 76 | on( 77 | event: "wait", 78 | listener: ({ lock, release }: { lock: Promise; release: Release }) => void, 79 | ): this; 80 | on(event: string, listener: (...args: unknown[]) => void): this; 81 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 82 | on(event: string, listener: (...args: any[]) => void): this { 83 | return super.on(event, listener); 84 | } 85 | 86 | once(event: "lock" | "unlock" | "all-clear", listener: () => void): this; 87 | once( 88 | event: "wait", 89 | listener: ({ lock, release }: { lock: Promise; release: Release }) => void, 90 | ): this; 91 | once(event: string, listener: (...args: unknown[]) => void): this; 92 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 93 | once(event: string, listener: (...args: any[]) => void): this { 94 | return super.once(event, listener); 95 | } 96 | } 97 | 98 | export class RateLimiter extends Mutex { 99 | private time_mutex: Mutex; 100 | private count = 0; 101 | private last = 0; 102 | private timer?: NodeJS.Timeout; 103 | public interval: number; 104 | 105 | constructor({ limit = 20, interval = 10_000, concurrent = 2 } = {}) { 106 | super(concurrent); 107 | this.time_mutex = new Mutex(limit); 108 | this.interval = interval; 109 | 110 | this.time_mutex.on("lock", (...args) => this.emit("time-lock", ...args)); 111 | this.time_mutex.on("unlock", (...args) => this.emit("time-unlock", ...args)); 112 | } 113 | 114 | async lock(): Promise { 115 | if (this.last + this.interval < Date.now()) { 116 | this.reset(); 117 | } else if (this.time_mutex.full() && !this.timer) { 118 | this.cleaner(); 119 | } 120 | 121 | await this.time_mutex.lock(); 122 | this.count++; 123 | return super.lock(); 124 | } 125 | 126 | reset(): void { 127 | while (this.count > 0) { 128 | this.time_mutex.unlock(); 129 | this.count--; 130 | } 131 | 132 | this.last = Date.now(); 133 | 134 | this.emit("timer-reset"); 135 | } 136 | 137 | cleaner(): void { 138 | this.timer = setTimeout( 139 | () => { 140 | this.reset(); 141 | 142 | setTimeout(() => { 143 | if (this.time_mutex.waiting() > 0) { 144 | this.cleaner(); 145 | } else { 146 | this.timer = undefined; 147 | } 148 | }, 0); 149 | }, 150 | this.last + this.interval - Date.now(), 151 | ); 152 | } 153 | 154 | set limit(limit: number) { 155 | this.time_mutex.resize(limit); 156 | } 157 | 158 | emit(event: "lock" | "unlock" | "all-clear"): boolean; 159 | emit(event: "wait", { lock, release }: { lock: Promise; release: Release }): boolean; 160 | emit(event: "time-lock" | "time-unlock" | "timer-reset"): boolean; 161 | emit(event: string): boolean; 162 | emit(event: string, ...args: unknown[]): boolean { 163 | // @ts-expect-error super 164 | return super.emit(event, ...args); 165 | } 166 | 167 | on(event: "lock" | "unlock" | "all-clear", listener: () => void): this; 168 | on( 169 | event: "wait", 170 | listener: ({ lock, release }: { lock: Promise; release: Release }) => void, 171 | ): this; 172 | on(event: "time-lock" | "time-unlock" | "timer-reset", listener: () => void): this; 173 | on(event: string, listener: (...args: unknown[]) => void): this; 174 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 175 | on(event: string, listener: (...args: any[]) => void): this { 176 | return super.on(event, listener); 177 | } 178 | 179 | once(event: "lock" | "unlock" | "all-clear", listener: () => void): this; 180 | once( 181 | event: "wait", 182 | listener: ({ lock, release }: { lock: Promise; release: Release }) => void, 183 | ): this; 184 | once(event: "time-lock" | "time-unlock" | "timer-reset", listener: () => void): this; 185 | once(event: string, listener: (...args: unknown[]) => void): this; 186 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 187 | once(event: string, listener: (...args: any[]) => void): this { 188 | return super.once(event, listener); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { RecentSubmission, UserProfile } from "./leetcode-types"; 2 | 3 | /////////////////////////////////////////////////////////////////////////////// 4 | // Cache 5 | export interface CacheItem { 6 | /** 7 | * The key of the item. 8 | */ 9 | key: string; 10 | 11 | /** 12 | * The value of the item. 13 | */ 14 | value: unknown; 15 | 16 | /** 17 | * The expiration time of the item in milliseconds since the Unix epoch. 18 | */ 19 | expires: number; 20 | } 21 | 22 | /** 23 | * A simple in-memory cache table. 24 | */ 25 | export interface CacheTable { 26 | [key: string]: CacheItem; 27 | } 28 | 29 | /////////////////////////////////////////////////////////////////////////////// 30 | // Credential 31 | export interface ICredential { 32 | /** 33 | * The authentication session. 34 | */ 35 | session?: string; 36 | 37 | /** 38 | * The csrf token. 39 | */ 40 | csrf?: string; 41 | } 42 | 43 | /////////////////////////////////////////////////////////////////////////////// 44 | // LeetCode GraphQL 45 | export interface LeetCodeGraphQLQuery { 46 | operationName?: string; 47 | variables?: { [key: string]: unknown }; 48 | query: string; 49 | headers?: { [key: string]: string }; 50 | } 51 | 52 | export interface LeetCodeGraphQLResponse { 53 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 | data: UserProfile | RecentSubmission[] | any; 55 | } 56 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function parse_cookie(cookie: string): Record { 2 | return cookie 3 | .split(";") 4 | .map((x) => x.trim().split("=")) 5 | .reduce( 6 | (acc, x) => { 7 | if (x.length !== 2) { 8 | return acc; 9 | } 10 | if (x[0].endsWith("csrftoken")) { 11 | acc["csrftoken"] = x[1]; 12 | } else { 13 | acc[x[0]] = x[1]; 14 | } 15 | return acc; 16 | }, 17 | {} as Record, 18 | ); 19 | } 20 | 21 | export function sleep(ms: number, val: unknown = null): Promise { 22 | return new Promise((resolve) => setTimeout(() => resolve(val), ms)); 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | "transpileOnly": true, 4 | "files": true 5 | }, 6 | "exclude": ["lib", "src/_tests", "tsup.config.ts"], 7 | "compilerOptions": { 8 | "target": "es2020" /* Node 14+ */, 9 | "lib": ["es2020"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "outDir": "./lib", 13 | "declaration": true, 14 | "resolveJsonModule": true, 15 | "noEmitOnError": true, 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "typeRoots": ["node_modules/@types"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig((options) => ({ 4 | entry: ["src/index.ts"], 5 | outDir: "lib", 6 | target: "node18", 7 | format: ["cjs", "esm"], 8 | clean: true, 9 | splitting: false, 10 | dts: options.watch ? false : { resolve: true }, 11 | esbuildOptions(opt) { 12 | opt.loader = { 13 | ".graphql": "text", 14 | }; 15 | }, 16 | })); 17 | --------------------------------------------------------------------------------