├── .gitignore ├── queue.yaml ├── dispatch.yaml ├── .dockerignore ├── default ├── app.yaml ├── requirements.txt ├── templates │ ├── main.html │ ├── interpret.html │ ├── base.html │ ├── results.html │ └── form.html └── main.py ├── requirements.txt ├── worker ├── requirements.txt ├── app.yaml ├── templates │ ├── main.html │ └── base.html └── main.py ├── cron.yaml ├── README.md └── .gcloudignore /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | -------------------------------------------------------------------------------- /queue.yaml: -------------------------------------------------------------------------------- 1 | queue: 2 | - name: default 3 | rate: 1/s 4 | -------------------------------------------------------------------------------- /dispatch.yaml: -------------------------------------------------------------------------------- 1 | dispatch: 2 | - url: "*/" 3 | service: default 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | README.md 3 | *.pyc 4 | *.pyo 5 | *.pyd 6 | __pycache__ 7 | .pytest_cache 8 | -------------------------------------------------------------------------------- /default/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python39 2 | app_engine_apis: true 3 | service: default 4 | 5 | handlers: 6 | - url: /.* 7 | script: auto 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask[async] 2 | wtforms 3 | gunicorn 4 | python-leetcode 5 | google-cloud-secret-manager 6 | appengine-python-standard 7 | -------------------------------------------------------------------------------- /worker/requirements.txt: -------------------------------------------------------------------------------- 1 | flask[async] 2 | gunicorn 3 | python-leetcode==1.0.10 4 | google-cloud-secret-manager 5 | appengine-python-standard 6 | -------------------------------------------------------------------------------- /default/requirements.txt: -------------------------------------------------------------------------------- 1 | flask[async] 2 | wtforms 3 | gunicorn 4 | python-leetcode==1.0.10 5 | google-cloud-secret-manager 6 | appengine-python-standard 7 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: "trigger cache invalidation" 3 | url: /invalidate_cache_schedule 4 | schedule: every 10 minutes 5 | target: worker 6 | -------------------------------------------------------------------------------- /worker/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python39 2 | app_engine_apis: true 3 | service: worker 4 | 5 | handlers: 6 | - url: /invalidate_cache_schedule 7 | script: auto 8 | - url: /invalidate_cache 9 | script: auto 10 | -------------------------------------------------------------------------------- /default/templates/main.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Leetcode Helper{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 | {% include "form.html" %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /default/templates/interpret.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Leetcode Helper{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 | 9 | {% include 'form.html' %} 10 | {% include 'results.html' %} 11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grind helper (for Leetcode) 2 | 3 | This is the source of a website that helps you to grind leetcode. 4 | 5 | Here is the test instance of the website https://avid-life-90820.appspot.com/ 6 | 7 | The main idea is to help you to decide what to do next by analysing your previous activity. Based on your previous progress it will pinpoint your weak topics and will suggest you the next task to do. 8 | 9 | Example output: 10 | 11 | ![Selection_321](https://user-images.githubusercontent.com/1616237/136713979-00b80392-cc18-49d4-b9d2-e7b4a97baa32.png) 12 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | 17 | # Python pycache: 18 | __pycache__/ 19 | # Ignored by the build system 20 | /setup.cfg 21 | 22 | .mypy_cache 23 | README.md 24 | Dockerfile 25 | -------------------------------------------------------------------------------- /worker/templates/main.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block header %} 4 |

{% block title %}Main{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | Submit problems JSON 10 | JSON can be obtained here https://leetcode.com/api/problems/algorithms/
11 | You have to be logged in in order to get a valid output.
12 |
13 | 14 |
15 | 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /default/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block title %}{% endblock %} 11 | 12 | 13 | 14 |
15 |
16 | {% block header %} 17 | {% endblock %} 18 | {% block content %}{% endblock %} 19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /worker/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block title %}{% endblock %} 11 | 12 | 13 | 14 |
15 |
16 | {% block header %} 17 | {% endblock %} 18 | {% block content %}{% endblock %} 19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /default/templates/results.html: -------------------------------------------------------------------------------- 1 |

Problem for you

2 | Here is the problem for you to solve: 3 | 4 | {{ to_solve_name }} 5 | 6 |
7 | This one covers topics you're least familiar with. 8 | 9 |

Detailed topics breakdown

10 | 11 | 12 | {% for tag, progress in tag_progress.items() %} 13 | 14 | 19 | 24 | 25 | {% endfor %} 26 | 27 |
15 | 16 | {{ tag_to_name[tag] }} 17 | 18 | 20 | 21 | {{ "%.2f"|format(progress) }} 22 | 23 |
28 | -------------------------------------------------------------------------------- /default/templates/form.html: -------------------------------------------------------------------------------- 1 |
2 | Get your leetcode statistics 3 | Copy the content from 4 | 5 | https://leetcode.com/api/problems/algorithms/ 6 | 7 | into this form and see the magic happens 8 |
9 | (you must be logged in in order to get the correct JSON) 10 |
11 |
12 | {{ form.problems(placeholder="Paste JSON here") }} 13 |
14 | {% if form.problems.errors %} 15 | 20 | {% endif %} 21 | 22 |
23 | -------------------------------------------------------------------------------- /worker/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | from functools import lru_cache 5 | from typing import List, Tuple 6 | 7 | import google.appengine.api 8 | import leetcode 9 | from flask import Flask, render_template, request 10 | from google.appengine.api import memcache, taskqueue 11 | from google.cloud import secretmanager 12 | 13 | logging.getLogger().setLevel(logging.DEBUG) 14 | 15 | app = Flask(__name__) 16 | app.wsgi_app = google.appengine.api.wrap_wsgi_app(app.wsgi_app) 17 | app.logger.setLevel(logging.DEBUG) 18 | 19 | 20 | @lru_cache(None) 21 | def get_leetcode_client() -> leetcode.DefaultApi: 22 | """ 23 | Singleton to initialize leetcode client. Only one instance will be created 24 | """ 25 | client = secretmanager.SecretManagerServiceClient() 26 | 27 | csrf_token = client.access_secret_version( 28 | request={"name": "projects/779116764331/secrets/LEETCODE_CSRF_TOKEN/versions/1"} 29 | ).payload.data.decode("UTF-8") 30 | 31 | leetcode_session = client.access_secret_version( 32 | request={"name": "projects/779116764331/secrets/LEETCODE_SESSION/versions/1"} 33 | ).payload.data.decode("UTF-8") 34 | 35 | configuration = leetcode.Configuration() 36 | 37 | configuration.api_key["x-csrftoken"] = csrf_token 38 | configuration.api_key["csrftoken"] = csrf_token 39 | configuration.api_key["LEETCODE_SESSION"] = leetcode_session 40 | configuration.api_key["Referer"] = "https://leetcode.com" 41 | configuration.debug = False 42 | 43 | return leetcode.DefaultApi(leetcode.ApiClient(configuration)) 44 | 45 | 46 | def get_problem_detail(slug: str) -> leetcode.GraphqlQuestionDetail: 47 | """ 48 | Make a request to leetcode API to get more details about the question 49 | """ 50 | graphql_request = leetcode.GraphqlQuery( 51 | query=""" 52 | query getQuestionDetail($titleSlug: String!) { 53 | question(titleSlug: $titleSlug) { 54 | title 55 | topicTags { 56 | name 57 | slug 58 | } 59 | } 60 | } 61 | """, 62 | variables=leetcode.GraphqlQueryVariables(title_slug=slug), 63 | operation_name="getQuestionDetail", 64 | ) 65 | 66 | api_instance = get_leetcode_client() 67 | 68 | api_response = api_instance.graphql_post(body=graphql_request) 69 | 70 | return api_response.data.question 71 | 72 | 73 | def check_cache_tag(slug: str) -> bool: 74 | """ 75 | Check the information about a tag is in the cache 76 | """ 77 | return memcache.Client().get(f"tag_{slug}_name") is not None 78 | 79 | 80 | def check_cache_problem(slug: str) -> bool: 81 | """ 82 | Check we cached all the information about the problem 83 | """ 84 | return ( 85 | memcache.Client().get(f"{slug}_tags") is not None 86 | and memcache.Client().get(f"problem_{slug}_tags") is not None 87 | and all( 88 | check_cache_tag(slug) 89 | for slug in memcache.Client().get(f"problem_{slug}_tags") 90 | ) 91 | is not None 92 | and memcache.Client().get(f"problem_{slug}_title") is not None 93 | ) 94 | 95 | 96 | @app.route("/invalidate_cache_schedule", methods=["GET"]) 97 | def invalidate_cache_schedule() -> Tuple[str, int]: 98 | """ 99 | Create tasks to invalidate the information about all the leetcode 100 | problems. Those tasks will be later picked up by workers 101 | """ 102 | 103 | # Authenticate appengine cron 104 | if not request.headers.get("X-AppEngine-Cron", False): 105 | logging.error("No cron header set") 106 | return "FAIL", 403 107 | 108 | api_instance = get_leetcode_client() 109 | 110 | response: List[str] = [] 111 | 112 | for topic in ["algorithms", "shell", "databases", "concurrency"]: 113 | api_response = api_instance.api_problems_topic_get(topic=topic) 114 | 115 | for slug in ( 116 | pair.stat.question__title_slug for pair in api_response.stat_status_pairs 117 | ): 118 | logging.info(f"Schedule invalidation for {slug}") 119 | if check_cache_problem(slug): 120 | logging.info(f"{slug} is already in the cache") 121 | else: 122 | task = taskqueue.add( 123 | url="/invalidate_cache", target="worker", params={"slug": slug} 124 | ) 125 | 126 | response.append( 127 | f"Task {task.name} for slug {slug} enqueued, ETA {task.eta}." 128 | ) 129 | 130 | return "
".join(response), 200 131 | 132 | 133 | @app.route("/invalidate_cache", methods=["POST"]) 134 | def invalidate_cache() -> Tuple[str, int]: 135 | """ 136 | Method to cache the information about a particular problem 137 | """ 138 | # Authenticate appengine queue 139 | if not request.headers.get("X-AppEngine-QueueName", False): 140 | logging.error("No task queue header set") 141 | return "FAIL", 403 142 | 143 | # Set randon TTL, so cache doesn't expire all at once 144 | cache_for = 86400 * 30 + random.randint(-86400, 86400) 145 | 146 | slug = request.form["slug"] 147 | if check_cache_problem(slug): 148 | logging.info(f"{slug} is already in the cache") 149 | else: 150 | logging.info(f"Updating slug {slug}") 151 | 152 | detail = get_problem_detail(slug) 153 | tags = [tag.slug for tag in detail.topic_tags] 154 | 155 | for tag in detail.topic_tags: 156 | logging.info(f"Updating tag {tag.slug} for problem {slug}") 157 | memcache.Client().set( 158 | f"tag_{tag.slug}_name", 159 | tag.name, 160 | time=cache_for, 161 | ) 162 | 163 | memcache.Client().set(f"{slug}_tags", tags, time=cache_for) 164 | memcache.Client().set( 165 | f"problem_{slug}_tags", 166 | tags, 167 | time=cache_for, 168 | ) 169 | memcache.Client().set( 170 | f"problem_{slug}_title", 171 | detail.title, 172 | time=cache_for, 173 | ) 174 | 175 | logging.info(f"Finished updating slug {slug}") 176 | 177 | return "OK", 200 178 | 179 | 180 | @app.route("/") 181 | def main(): 182 | return render_template("main.html") 183 | 184 | 185 | if __name__ == "__main__": 186 | app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080))) 187 | -------------------------------------------------------------------------------- /default/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import random 6 | import re 7 | from dataclasses import dataclass, field 8 | from typing import Any, Dict, List, Set, Tuple 9 | 10 | import google.appengine.api 11 | import wtforms 12 | from flask import Flask, render_template, request 13 | from google.appengine.api import memcache 14 | 15 | logging.getLogger().setLevel(logging.DEBUG) 16 | app = Flask(__name__) 17 | app.wsgi_app = google.appengine.api.wrap_wsgi_app(app.wsgi_app) 18 | app.logger.setLevel(logging.DEBUG) 19 | 20 | 21 | class LeetcodeProblemsForms(wtforms.Form): 22 | problems = wtforms.TextAreaField("problems", [wtforms.validators.InputRequired()]) 23 | 24 | def validate_problems(self, form_field): 25 | """ 26 | Validate json input of the form 27 | 28 | This is the data one can get from https://leetcode.com/api/problems/algorithms/ 29 | 30 | Example of data you can get. 31 | 32 | { 33 | "user_name":"omgitspavel", 34 | "num_solved":770, 35 | "num_total":1840, 36 | "ac_easy":182, 37 | "ac_medium":469, 38 | "ac_hard":119, 39 | "stat_status_pairs":[ 40 | { 41 | "stat":{ 42 | "question_id":2162, 43 | "question__article__live":null, 44 | "question__article__slug":null, 45 | "question__article__has_video_solution":null, 46 | "question__title":"Partition Array Into Two Arrays to Minimize Sum Difference", 47 | "question__title_slug":"partition-array-into-two-arrays-to-minimize-sum-difference", 48 | "question__hide":false, 49 | "total_acs":1237, 50 | "total_submitted":6813, 51 | "frontend_question_id":2035, 52 | "is_new_question":false 53 | }, 54 | "status":null, 55 | "difficulty":{ 56 | "level":3 57 | }, 58 | "paid_only":false, 59 | "is_favor":false, 60 | "frequency":0, 61 | "progress":0.0 62 | } 63 | ] 64 | } 65 | """ 66 | data = json.loads(form_field.data) 67 | 68 | if "stat_status_pairs" not in data: 69 | raise wtforms.ValidationError("No stat_status_pairs in request") 70 | 71 | stat_status_pairs = data["stat_status_pairs"] 72 | 73 | if not isinstance(stat_status_pairs, list): 74 | raise wtforms.ValidationError("stat_status_pairs must be a list") 75 | 76 | for stat in stat_status_pairs: 77 | if "stat" not in stat: 78 | raise wtforms.ValidationError("Provided JSON is not correct") 79 | 80 | question_data = stat["stat"] 81 | 82 | if "question__title_slug" not in question_data: 83 | raise wtforms.ValidationError("Provided JSON is not correct") 84 | 85 | slug = question_data["question__title_slug"] 86 | 87 | if not isinstance(slug, str): 88 | raise wtforms.ValidationError("Provided JSON is not correct") 89 | 90 | if not re.match("[a-z0-9][a-z0-9-]*[a-z0-9]", slug): 91 | raise wtforms.ValidationError("Provided JSON is not correct") 92 | 93 | 94 | async def get_tags(slug: str) -> List[str]: 95 | """ 96 | Get tags for problem slug from the cache 97 | """ 98 | tags: List[str] = [] 99 | 100 | try: 101 | tags = memcache.Client().get(f"{slug}_tags") or [] 102 | except Exception: 103 | logging.exception("Failed to get tags for %s", slug) 104 | 105 | return tags 106 | 107 | 108 | @dataclass 109 | class TagInfo: 110 | solved: Set[str] = field(default_factory=set) 111 | left: Set[str] = field(default_factory=set) 112 | 113 | 114 | async def interpret( 115 | problems: Dict[str, Any], form: LeetcodeProblemsForms 116 | ) -> Tuple[str, int]: 117 | """ 118 | Interpret problems data got from the form. 119 | 120 | Two main outcomes of this method: 121 | 1. Calculate solved/total ratio per tag 122 | 2. Pick a random problem to solve bases on this ratio 123 | (least touched tags are prioritized) 124 | """ 125 | slug_to_solved_status = { 126 | pair["stat"]["question__title_slug"]: True if pair["status"] == "ac" else False 127 | for pair in problems["stat_status_pairs"] 128 | } 129 | 130 | all_problems = list(slug_to_solved_status.keys()) 131 | 132 | # Get the data of all tags for the problems 133 | tasks = [asyncio.create_task(get_tags(slug)) for slug in all_problems] 134 | results = await asyncio.gather(*tasks) 135 | problem_to_tags: Dict[str, List[str]] = { 136 | problem: tags for problem, tags in zip(all_problems, results) 137 | } 138 | 139 | # For each tag aggregate statistics of solved/left problems 140 | tag_info_map: Dict[str, TagInfo] = {} 141 | 142 | for slug, solved in list(slug_to_solved_status.items()): 143 | tags = problem_to_tags[slug] 144 | 145 | for tag in tags: 146 | tag_info_map.setdefault(tag, TagInfo()) 147 | if solved: 148 | tag_info_map[tag].solved.add(slug) 149 | else: 150 | tag_info_map[tag].left.add(slug) 151 | 152 | # Start with "to solve" list equal to all problems possible 153 | to_solve_tasks: Set[str] = {slug for slug in slug_to_solved_status.keys()} 154 | to_solve = random.choice(list(to_solve_tasks)) 155 | tag_progress: Dict[str, float] = {} 156 | 157 | # Go over the tags in the descending solved/total ratio order 158 | # (so the tags with the lowest ratio go first) 159 | for name, tag_info in sorted( 160 | tag_info_map.items(), 161 | key=lambda x: len(x[1].solved) / (len(x[1].solved) + len(x[1].left)), 162 | ): 163 | # Each step filter out the problems that are not related to this tag 164 | to_solve_tasks &= tag_info.left 165 | 166 | # And pick a new problem out of this reduced set (if there are any left) 167 | to_solve = random.choice(list(to_solve_tasks)) if to_solve_tasks else to_solve 168 | 169 | # Calculate the solved/total ratio 170 | solved_ratio = ( 171 | len(tag_info.solved) / (len(tag_info.solved) + len(tag_info.left)) * 100 172 | ) 173 | 174 | tag_progress[name] = solved_ratio 175 | 176 | # Resolve the human name for the problem we picked 177 | to_solve_name = memcache.Client().get(f"problem_{to_solve}_title") or to_solve 178 | 179 | # Resolve the human name for each tag 180 | tag_to_name = { 181 | slug: (memcache.Client().get(f"tag_{slug}_name") or slug) 182 | for slug, _ in tag_progress.items() 183 | } 184 | 185 | return ( 186 | render_template( 187 | "interpret.html", 188 | to_solve=to_solve, 189 | to_solve_name=to_solve_name, 190 | tag_progress=tag_progress, 191 | tag_to_name=tag_to_name, 192 | form=form, 193 | ), 194 | 200, 195 | ) 196 | 197 | 198 | @app.route("/", methods=["GET", "POST"]) 199 | async def main(): 200 | form = LeetcodeProblemsForms(request.form) 201 | 202 | if request.method == "POST" and form.validate(): 203 | # Form has been submitted and valid 204 | problems = json.loads(request.form["problems"]) 205 | return await interpret(problems, form) 206 | else: 207 | # Nothing submitted return empty form 208 | return render_template("main.html", form=form) 209 | 210 | 211 | if __name__ == "__main__": 212 | app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080))) 213 | --------------------------------------------------------------------------------