|
1 | 1 | import asyncio
|
| 2 | +import time |
2 | 3 | from contextlib import asynccontextmanager
|
3 | 4 | from fastapi import FastAPI, HTTPException
|
4 | 5 | import httpx
|
| 6 | +from typing import Dict |
5 | 7 | import uvicorn
|
6 | 8 |
|
7 |
| -url = "https://leetcode.com/graphql" |
| 9 | +app = FastAPI() |
| 10 | +leetcode_url = "https://leetcode.com/graphql" |
| 11 | +client = httpx.AsyncClient() |
8 | 12 |
|
9 |
| -all_questions = [] |
10 |
| -question_details = {} |
11 |
| -id_to_slug = {} |
12 |
| -slug_to_id = {} |
13 |
| -frontendid_to_slug = {} |
14 |
| -slug_to_frontendid = {} |
| 13 | +class QuestionCache: |
| 14 | + def __init__(self): |
| 15 | + self.questions: Dict[str, dict] = {} |
| 16 | + self.slug_to_id: Dict[str, str] = {} |
| 17 | + self.frontend_id_to_slug: Dict[str, str] = {} |
| 18 | + self.question_details: Dict[str, dict] = {} |
| 19 | + self.last_updated: float = 0 |
| 20 | + self.update_interval: int = 3600 |
| 21 | + self.lock = asyncio.Lock() |
15 | 22 |
|
16 |
| -async def fetch_all_questions_data(): |
17 |
| - async with httpx.AsyncClient() as client: |
18 |
| - payload = { |
19 |
| - "query": """query problemsetQuestionList($categorySlug: String, $limit: Int, $skip: Int, $filters: QuestionListFilterInput) { |
20 |
| - problemsetQuestionList: questionList( |
21 |
| - categorySlug: $categorySlug |
22 |
| - limit: $limit |
23 |
| - skip: $skip |
24 |
| - filters: $filters |
25 |
| - ) { |
26 |
| - total: totalNum |
27 |
| - questions: data { |
28 |
| - questionId |
29 |
| - questionFrontendId |
30 |
| - title |
31 |
| - titleSlug |
32 |
| - } |
| 23 | + async def initialize(self): |
| 24 | + async with self.lock: |
| 25 | + if not self.questions or (time.time() - self.last_updated) > self.update_interval: |
| 26 | + await self._fetch_all_questions() |
| 27 | + self.last_updated = time.time() |
| 28 | + |
| 29 | + async def _fetch_all_questions(self): |
| 30 | + query = """query problemsetQuestionList { |
| 31 | + problemsetQuestionList: questionList( |
| 32 | + categorySlug: "" |
| 33 | + limit: 10000 |
| 34 | + skip: 0 |
| 35 | + filters: {} |
| 36 | + ) { |
| 37 | + questions: data { |
| 38 | + questionId |
| 39 | + questionFrontendId |
| 40 | + title |
| 41 | + titleSlug |
33 | 42 | }
|
34 |
| - }""", |
35 |
| - "variables": {"categorySlug": "", "limit": 10000, "skip": 0, "filters": {}} |
36 |
| - } |
| 43 | + } |
| 44 | + }""" |
| 45 | + |
37 | 46 | try:
|
38 |
| - response = await client.post(url, json=payload) |
| 47 | + response = await client.post(leetcode_url, json={"query": query}) |
39 | 48 | if response.status_code == 200:
|
40 | 49 | data = response.json()
|
41 |
| - global all_questions, id_to_slug, slug_to_id, frontendid_to_slug, slug_to_frontendid |
42 |
| - all_questions = data["data"]["problemsetQuestionList"]["questions"] |
43 |
| - id_to_slug.clear() |
44 |
| - slug_to_id.clear() |
45 |
| - frontendid_to_slug.clear() |
46 |
| - slug_to_frontendid.clear() |
47 |
| - for q in all_questions: |
48 |
| - id_to_slug[q["questionId"]] = q["titleSlug"] |
49 |
| - slug_to_id[q["titleSlug"]] = q["questionId"] |
50 |
| - frontendid_to_slug[q["questionFrontendId"]] = q["titleSlug"] |
51 |
| - slug_to_frontendid[q["titleSlug"]] = q["questionFrontendId"] |
| 50 | + questions = data["data"]["problemsetQuestionList"]["questions"] |
| 51 | + |
| 52 | + self.questions.clear() |
| 53 | + self.slug_to_id.clear() |
| 54 | + self.frontend_id_to_slug.clear() |
| 55 | + |
| 56 | + for q in questions: |
| 57 | + self.questions[q["questionId"]] = q |
| 58 | + self.slug_to_id[q["titleSlug"]] = q["questionId"] |
| 59 | + self.frontend_id_to_slug[q["questionFrontendId"]] = q["titleSlug"] |
52 | 60 | except Exception as e:
|
53 |
| - print(f"Error fetching question list: {e}") |
| 61 | + print(f"Error updating questions: {e}") |
54 | 62 |
|
55 |
| -async def fetch_question_data(title_slug: str): |
56 |
| - async with httpx.AsyncClient() as client: |
57 |
| - for _ in range(5): |
58 |
| - payload = { |
59 |
| - "query": """query questionData($titleSlug: String!) { |
60 |
| - question(titleSlug: $titleSlug) { |
61 |
| - questionId |
62 |
| - questionFrontendId |
63 |
| - title |
64 |
| - content |
65 |
| - likes |
66 |
| - dislikes |
67 |
| - stats |
68 |
| - similarQuestions |
69 |
| - categoryTitle |
70 |
| - hints |
71 |
| - topicTags { name } |
72 |
| - companyTags { name } |
73 |
| - difficulty |
74 |
| - isPaidOnly |
75 |
| - solution { canSeeDetail content } |
76 |
| - hasSolution |
77 |
| - hasVideoSolution |
78 |
| - } |
79 |
| - }""", |
80 |
| - "variables": {"titleSlug": title_slug} |
81 |
| - } |
82 |
| - try: |
83 |
| - response = await client.post(url, json=payload) |
84 |
| - if response.status_code == 200: |
85 |
| - data = response.json() |
86 |
| - question = data["data"]["question"] |
87 |
| - question["url"] = f"https://leetcode.com/problems/{title_slug}/" |
88 |
| - return question |
89 |
| - except Exception as e: |
90 |
| - print(f"Error fetching question data: {e}") |
91 |
| - await asyncio.sleep(1) |
92 |
| - return None |
93 |
| - |
94 |
| -async def periodic_cache_update(): |
95 |
| - while True: |
96 |
| - await fetch_all_questions_data() |
97 |
| - await asyncio.sleep(3600) |
| 63 | +cache = QuestionCache() |
98 | 64 |
|
99 | 65 | @asynccontextmanager
|
100 | 66 | async def lifespan(app: FastAPI):
|
101 |
| - await fetch_all_questions_data() |
102 |
| - update_task = asyncio.create_task(periodic_cache_update()) |
| 67 | + await cache.initialize() |
103 | 68 | yield
|
104 |
| - update_task.cancel() |
105 |
| - try: |
106 |
| - await update_task |
107 |
| - except asyncio.CancelledError: |
108 |
| - pass |
109 | 69 |
|
110 | 70 | app = FastAPI(lifespan=lifespan)
|
111 | 71 |
|
| 72 | +async def fetch_with_retry(url: str, payload: dict, retries: int = 3): |
| 73 | + for _ in range(retries): |
| 74 | + try: |
| 75 | + response = await client.post(url, json=payload) |
| 76 | + if response.status_code == 200: |
| 77 | + return response.json() |
| 78 | + except Exception as e: |
| 79 | + print(f"Request failed: {e}") |
| 80 | + await asyncio.sleep(1) |
| 81 | + return None |
| 82 | + |
112 | 83 | @app.get("/questions")
|
113 | 84 | async def get_all_questions():
|
| 85 | + await cache.initialize() |
114 | 86 | return [{
|
115 | 87 | "id": q["questionId"],
|
116 | 88 | "frontend_id": q["questionFrontendId"],
|
117 | 89 | "title": q["title"],
|
118 | 90 | "title_slug": q["titleSlug"],
|
119 | 91 | "url": f"https://leetcode.com/problems/{q['titleSlug']}/"
|
120 |
| - } for q in all_questions] |
| 92 | + } for q in cache.questions.values()] |
121 | 93 |
|
122 | 94 | @app.get("/question/{identifier}")
|
123 | 95 | async def get_question(identifier: str):
|
124 |
| - if identifier in frontendid_to_slug: |
125 |
| - slug = frontendid_to_slug[identifier] |
126 |
| - elif identifier in slug_to_id: |
| 96 | + await cache.initialize() |
| 97 | + |
| 98 | + if identifier in cache.frontend_id_to_slug: |
| 99 | + slug = cache.frontend_id_to_slug[identifier] |
| 100 | + elif identifier in cache.slug_to_id: |
127 | 101 | slug = identifier
|
128 | 102 | else:
|
129 | 103 | raise HTTPException(status_code=404, detail="Question not found")
|
130 |
| - qid = slug_to_id[slug] |
131 |
| - if qid not in question_details: |
132 |
| - data = await fetch_question_data(slug) |
133 |
| - if not data: |
134 |
| - raise HTTPException(status_code=404, detail="Question data unavailable") |
135 |
| - question_details[qid] = data |
136 |
| - return question_details[qid] |
| 104 | + |
| 105 | + # check cache |
| 106 | + question_id = cache.slug_to_id[slug] |
| 107 | + if question_id in cache.question_details: |
| 108 | + return cache.question_details[question_id] |
| 109 | + |
| 110 | + # not in cache, fetch from leetcode |
| 111 | + query = """query questionData($titleSlug: String!) { |
| 112 | + question(titleSlug: $titleSlug) { |
| 113 | + questionId |
| 114 | + questionFrontendId |
| 115 | + title |
| 116 | + content |
| 117 | + likes |
| 118 | + dislikes |
| 119 | + stats |
| 120 | + similarQuestions |
| 121 | + categoryTitle |
| 122 | + hints |
| 123 | + topicTags { name } |
| 124 | + companyTags { name } |
| 125 | + difficulty |
| 126 | + isPaidOnly |
| 127 | + solution { canSeeDetail content } |
| 128 | + hasSolution |
| 129 | + hasVideoSolution |
| 130 | + } |
| 131 | + }""" |
| 132 | + |
| 133 | + payload = { |
| 134 | + "query": query, |
| 135 | + "variables": {"titleSlug": slug} |
| 136 | + } |
| 137 | + |
| 138 | + data = await fetch_with_retry(leetcode_url, payload) |
| 139 | + if not data or "data" not in data or not data["data"]["question"]: |
| 140 | + raise HTTPException(status_code=404, detail="Question data not found") |
| 141 | + |
| 142 | + question_data = data["data"]["question"] |
| 143 | + question_data["url"] = f"https://leetcode.com/problems/{slug}/" |
| 144 | + |
| 145 | + cache.question_details[question_id] = question_data |
| 146 | + return question_data |
137 | 147 |
|
138 | 148 | @app.get("/user/{username}")
|
139 | 149 | async def get_user_profile(username: str):
|
@@ -173,7 +183,7 @@ async def get_user_profile(username: str):
|
173 | 183 | }
|
174 | 184 |
|
175 | 185 | try:
|
176 |
| - response = await client.post(url, json=payload) |
| 186 | + response = await client.post(leetcode_url, json=payload) |
177 | 187 | if response.status_code == 200:
|
178 | 188 | data = response.json()
|
179 | 189 | if not data.get("data", {}).get("matchedUser"):
|
@@ -204,7 +214,7 @@ async def get_daily_challenge():
|
204 | 214 | payload = {"query": query}
|
205 | 215 |
|
206 | 216 | try:
|
207 |
| - response = await client.post(url, json=payload) |
| 217 | + response = await client.post(leetcode_url, json=payload) |
208 | 218 | if response.status_code == 200:
|
209 | 219 | data = response.json()
|
210 | 220 | return data["data"]["activeDailyCodingChallengeQuestion"]
|
@@ -241,7 +251,7 @@ async def get_user_contest_history(username: str):
|
241 | 251 | }
|
242 | 252 |
|
243 | 253 | try:
|
244 |
| - response = await client.post(url, json=payload) |
| 254 | + response = await client.post(leetcode_url, json=payload) |
245 | 255 | if response.status_code == 200:
|
246 | 256 | data = response.json()
|
247 | 257 | if not data.get("data"):
|
@@ -271,7 +281,7 @@ async def get_recent_submissions(username: str, limit: int = 20):
|
271 | 281 | }
|
272 | 282 |
|
273 | 283 | try:
|
274 |
| - response = await client.post(url, json=payload) |
| 284 | + response = await client.post(leetcode_url, json=payload) |
275 | 285 | if response.status_code == 200:
|
276 | 286 | data = response.json()
|
277 | 287 | if "errors" in data:
|
@@ -308,7 +318,7 @@ async def get_problems_by_topic(topic: str):
|
308 | 318 | }
|
309 | 319 |
|
310 | 320 | try:
|
311 |
| - response = await client.post(url, json=payload) |
| 321 | + response = await client.post(leetcode_url, json=payload) |
312 | 322 | if response.status_code == 200:
|
313 | 323 | data = response.json()
|
314 | 324 | return data["data"]["problemsetQuestionList"]["questions"]
|
|
0 commit comments