├── .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 |
--------------------------------------------------------------------------------