Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,9 @@ dmypy.json

# IDE folders
.vscode
.idea
.idea

# Sensitive Information
leetcode_cookies.txt
problem_cache.json
*.session
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Before running the script, make sure that python3 is installed in your system.
If you prefer, you can use the Docker image to download LeetCode submissions. For more information read the
section [Docker Image](#docker-image).

### ✨ New Features Added
- **API Resilience**: Added retry logic with exponential backoff for GraphQL queries and submission requests to prevent crashes on network timeouts or rate limits.
- **Topic Tags Export**: Problems now extract and expose `topicTags` for categorizing solutions by LeetCode topics.

## 🏁 Getting started <a name="getting-started"></a>

### Download `leetcode-export`
Expand Down Expand Up @@ -224,6 +228,56 @@ extension: str
Default submission filename
template: `${date_formatted} - ${status_display} - runtime ${runtime} - memory ${memory}.${extension}`


## 🚀 Full Automation: Daily GitHub Sync

You can fully automate your LeetCode backup to a separate GitHub repository using **GitHub Actions** and the provided **Chrome Extension**.

### 1. Create your Solutions Repository
1. Create a new private GitHub repository (e.g., `LeetCode-Solutions`).
2. Create a file `.github/workflows/sync.yml` with the following content:

```yaml
name: Daily LeetCode Sync
on:
schedule:
- cron: "0 */8 * * *"
workflow_dispatch:

jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- run: |
pip install git+https://github.com/MohamedMousad/leetcode-export.git@feature/api-retry-and-tags
- env:
LEETCODE_COOKIE: ${{ secrets.LEETCODE_COOKIE }}
run: |
python -m leetcode_export --folder .
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Auto-sync: Daily update" || echo "No changes"
git push origin main
```

### 2. Set Up the Chrome Extension
Because LeetCode cookies change, we provide a Chrome Extension that automatically syncs your active session cookie to your GitHub repository securely as a secret.
1. Generate a **GitHub Personal Access Token (Classic)** with `repo` and `workflow` permissions.
2. Open Chrome and navigate to `chrome://extensions/`.
3. Enable **Developer mode** (top right corner).
4. Click **Load unpacked** and select the `chrome-extension` folder found in this repository.
5. Click the extension icon in your browser toolbar, paste your PAT and repository name (e.g., `YourUser/LeetCode-Solutions`), and click **Save**.
6. Whenever you log into LeetCode, click **Push Cookie to GitHub Now** in the extension to automatically securely update the `LEETCODE_COOKIE` secret in your repository.

Your GitHub action will now run seamlessly every day!

## Special mentions

Thanks to [skygragon](https://github.com/skygragon) for
Expand Down
84 changes: 84 additions & 0 deletions chrome-extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
importScripts('nacl.min.js', 'nacl-util.min.js', 'sealedbox.js');

const SECRET_NAME = "LEETCODE_COOKIE";

async function updateGitHubSecret() {
try {
const data = await chrome.storage.local.get(['github_token', 'github_repo']);
if (!data.github_token || !data.github_repo) {
return {status: "error", msg: "No GitHub token or repo configured."};
}

const [owner, repo] = data.github_repo.trim().split('/');
if (!owner || !repo) return {status: "error", msg: "Invalid repo format. Use owner/repo."};

const cookies = await chrome.cookies.getAll({ domain: "leetcode.com" });
if (cookies.length === 0) return {status: "error", msg: "No cookies found for leetcode.com!"};

let cookieStr = cookies.map(c => c.name + "=" + c.value).join("; ");
if (!cookieStr.includes("LEETCODE_SESSION")) {
return {status: "error", msg: "No LEETCODE_SESSION found. Are you logged in?"};
}

const keyRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`, {
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${data.github_token.trim()}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});

if (!keyRes.ok) {
const errTxt = await keyRes.text();
return {status: "error", msg: `Failed to fetch public key. Status: ${keyRes.status}. ${errTxt}`};
}
const keyData = await keyRes.json();

const messageBytes = nacl.util.decodeUTF8(cookieStr);
const keyBytes = nacl.util.decodeBase64(keyData.key);

const encryptedBytes = sealedBox.seal(messageBytes, keyBytes);
const encryptedBase64 = nacl.util.encodeBase64(encryptedBytes);

const uploadRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/actions/secrets/${SECRET_NAME}`, {
method: "PUT",
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${data.github_token.trim()}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json"
},
body: JSON.stringify({
encrypted_value: encryptedBase64,
key_id: keyData.key_id
})
});

if (uploadRes.ok || uploadRes.status === 201 || uploadRes.status === 204) {
chrome.storage.local.set({ last_sync: new Date().toLocaleString() });
return {status: "ok", msg: "Success!"};
} else {
const errTxt = await uploadRes.text();
return {status: "error", msg: `Failed to upload secret. Status: ${uploadRes.status}. ${errTxt}`};
}
} catch (e) {
return {status: "error", msg: "Exception: " + e.message};
}
}

// Run when alarm fires
chrome.alarms.create("syncAlarm", { periodInMinutes: 60 * 12 }); // Every 12 hours
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "syncAlarm") {
updateGitHubSecret();
}
});

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "sync_now") {
updateGitHubSecret().then((result) => {
sendResponse(result);
});
return true; // Keep message channel open for async response
}
});
21 changes: 21 additions & 0 deletions chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"manifest_version": 3,
"name": "LeetCode Auto-Sync",
"version": "1.0",
"description": "Automatically keeps your LeetCode cookies updated as a GitHub Action Secret.",
"permissions": [
"storage",
"alarms",
"cookies"
],
"host_permissions": [
"https://leetcode.com/*",
"https://api.github.com/*"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html"
}
}
1 change: 1 addition & 0 deletions chrome-extension/nacl-util.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions chrome-extension/nacl.min.js

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions chrome-extension/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { width: 320px; padding: 15px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
h3 { margin-top: 0; color: #333; }
label { font-size: 12px; font-weight: bold; color: #555; }
input, button { width: 100%; margin-bottom: 15px; padding: 8px; box-sizing: border-box; border-radius: 4px; border: 1px solid #ccc; }
button { background-color: #2ea44f; color: white; border: none; cursor: pointer; font-weight: bold; }
button:hover { background-color: #2c974b; }
#sync { background-color: #0366d6; margin-top: 10px; }
#sync:hover { background-color: #005cc5; }
#status { font-size: 13px; color: #555; font-weight: bold; text-align: center; }
</style>
</head>
<body>
<h3>LeetCode Auto-Sync</h3>

<label>GitHub Personal Access Token (repo scope):</label>
<input type="password" id="pat" placeholder="ghp_xxxxxxxx...">

<label>Repository (owner/repo):</label>
<input type="text" id="repo" placeholder="MohamedMousad/LeetCode-Solutions">

<button id="save">Save Settings</button>
<button id="sync">Push Cookie to GitHub Now</button>

<p id="status"></p>

<script src="popup.js"></script>
</body>
</html>
27 changes: 27 additions & 0 deletions chrome-extension/popup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
document.addEventListener('DOMContentLoaded', () => {
chrome.storage.local.get(['github_token', 'github_repo', 'last_sync'], (res) => {
if (res.github_token) document.getElementById('pat').value = res.github_token;
if (res.github_repo) document.getElementById('repo').value = res.github_repo;
if (res.last_sync) document.getElementById('status').innerText = "Last sync: " + res.last_sync;
});

document.getElementById('save').addEventListener('click', () => {
chrome.storage.local.set({
github_token: document.getElementById('pat').value,
github_repo: document.getElementById('repo').value
}, () => {
document.getElementById('status').innerText = "Settings saved! Extension is ready.";
});
});

document.getElementById('sync').addEventListener('click', () => {
document.getElementById('status').innerText = "Fetching cookie and pushing to GitHub...";
chrome.runtime.sendMessage({ action: "sync_now" }, (res) => {
if (res && res.status === "ok") {
document.getElementById('status').innerText = "Success! GitHub Secret Updated. \nYour daily GitHub Action will now run flawlessly.";
} else {
document.getElementById('status').innerText = "Error: " + (res ? res.msg : "Unknown error");
}
});
});
});
1 change: 1 addition & 0 deletions chrome-extension/sealedbox.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 25 additions & 9 deletions leetcode_export/leetcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,19 @@ def get_problem_statement(self, slug: str) -> Problem:
:param slug: problem identifier
:return: Problem
"""
response = self.session.post(GRAPHQL_URL, json=question_detail_json(slug))
if "data" in response.json() and "question" in response.json()["data"]:
problem_dict = dict_camelcase_to_snakecase(
response.json()["data"]["question"]
)
return Problem.from_dict(problem_dict)
for attempt in range(5):
try:
response = self.session.post(GRAPHQL_URL, json=question_detail_json(slug), timeout=20)
if "data" in response.json() and "question" in response.json()["data"]:
problem_dict = dict_camelcase_to_snakecase(
response.json()["data"]["question"]
)
return Problem.from_dict(problem_dict)
break
except requests.exceptions.RequestException as e:
logging.warning(f"GraphQL request failed: {e}. Retrying in {5 * (attempt + 1)} seconds...")
sleep(5 * (attempt + 1))
Comment on lines +129 to +138
return None
Comment on lines +127 to +139

def get_submissions(self) -> Iterator[Submission]:
"""
Expand All @@ -148,9 +155,18 @@ def get_submissions(self) -> Iterator[Submission]:
and response_json["has_next"]
):
logging.debug(f"Exporting submissions from {current} to {current + 20}")
response = self.session.get(SUBMISSIONS_API_URL.format(current, 20))
logging.debug(response.content)
response_json = response.json()
for attempt in range(5):
try:
response = self.session.get(SUBMISSIONS_API_URL.format(current, 20), timeout=20)
logging.debug(response.content)
response_json = response.json()
break
except requests.exceptions.RequestException as e:
logging.warning(f"Request failed: {e}. Retrying in {5 * (attempt + 1)} seconds...")
sleep(5 * (attempt + 1))
Comment on lines +158 to +166
else:
logging.error("Max retries exceeded for fetching submissions.")
break
if "submissions_dump" in response_json:
for submission_dict in response_json["submissions_dump"]:
submission_dict["runtime"] = submission_dict["runtime"].replace(
Expand Down
5 changes: 5 additions & 0 deletions leetcode_export/leetcode_graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class Problem:
title: str
title_slug: str
content: str
topic_tags: list


def question_detail_json(slug):
Expand All @@ -28,6 +29,10 @@ def question_detail_json(slug):
title
titleSlug
content
topicTags {
name
slug
}
}
}""",
}