├── .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 | 
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 |
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 |
15 |
16 | {{ tag_to_name[tag] }}
17 |
18 |
19 |
20 |
21 | {{ "%.2f"|format(progress) }}
22 |
23 |
24 |
25 | {% endfor %}
26 |
27 |
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 |
16 | {% for error in form.problems.errors %}
17 | {{ error }}
18 | {% endfor %}
19 |
20 | {% endif %}
21 | Submit
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 |
--------------------------------------------------------------------------------