GraphQL and decoders
Not a scrape. Public GraphQL only. No LeetCode session cookie.
Playground: playground. OpenAPI: docs.
Upstream is https://leetcode.com/graphql/. LeetCodeAPI POSTs with requests and referer: https://leetcode.com/{username}/. GraphQL errors become "user does not exist". Non-200 becomes "HTTP {code}". matchedUser: null is the same miss. JSON routes then return HTTP 200 status: "error". SVG returns 404.
Operations#
| Client method | Operation | What it pulls |
|---|---|---|
fetch_user_stats |
getUserProfile |
allQuestionsCount, submitStats, ranking, calendar |
fetch_contest_ranking |
getUserContestRanking |
rating, global rank, history |
fetch_user_profile |
getUserProfile (wider) |
profile, social, badges, recent 20 |
fetch_user_badges |
getUserBadges |
badges, upcoming, active |
fetch_user_heatmap |
getUserHeatmap then getUserYearHeatmap |
merged calendars |
fetch_skill_stats |
skillStats |
tagProblemCounts |
LeetCodeService is a thin wrapper. Routes and canonical_mapper call that class. Decoders live in services/decoders/ and re-export ResponseDecoder methods.
Heatmap years#
LeetCode’s flat matchedUser.submissionCalendar only returns about the last 12 months. Inactive users look empty. The client reads userCalendar.activeYears, fetches userCalendar(year:) per year, and merges timestamp counts. decode_heatmap then UTC-dates those stamps, fills every day from January 1 of first activity through today, and assigns levels 0 to 4 with ceil((count/max)*4).
That is why a first heatmap GET is slower than stats. It is N GraphQL calls, not one.
Contests vs missing users#
decode_contest_ranking treats userContestRanking is None as "user has no contest history". That string is not in the invalid-user markers. A real account that never sat a contest is not a 404.
History from GraphQL includes unattended contests. contests_from keeps attended rows only. Tests in tests/test_contests.py pin both behaviors.
Topics#
decode_skill_stats merges advanced / intermediate / fundamental tags, drops solved <= 0, sorts by count desc. /stats and /topics both call build_stats, so both hit getUserProfile and skillStats. /topics is not cheaper. It is a smaller JSON.
End-to-end walk#
POST https://leetcode.com/graphql/ HTTP/1.1
Content-Type: application/json
Referer: https://leetcode.com/khan-tashif/
{
"query": "query getUserProfile($username: String!) { matchedUser(username: $username) { ... } }",
"variables": { "username": "khan-tashif" }
}
sequenceDiagram
participant B as Browser
participant API as leetcode-stats
participant G as leetcode.com/graphql
B->>API: GET /khan-tashif/heatmap?view=all
API->>G: POST getUserHeatmap
loop each activeYear
API->>G: POST getUserYearHeatmap
end
Note over API: merge timestamp counts, fill days, ceil levels 0 to 4
API-->>B: envelope data HeatmapThe blocking requests client runs inside async middleware. That is history. Flask leftovers (FLASK_ENV) are not a second framework.