More Projects
59 in total
Codeboard
Moderator, contributor · Creative Computing Society · 2024
Competitive-programming leaderboard for the society. A Celery beat schedule fans out one scrape task per member's LeetCode profile every two minutes and chains the results into a leaderboard rebuild; Django REST + PostgreSQL on the back, a Create React App client on the front, Redis as the broker. Compose declares Redis and the web service but no worker or beat container, so the schedule that drives the whole thing is started by hand beside it.
Project Details
Moderator, contributor
Creative Computing Society
2024
Society · Backend
LeetCode has no API. It has a GraphQL endpoint the site itself talks to, and a rule that a submission only counts if you solved the question after the community posted it — which is the whole reason Codeboard exists rather than just linking everyone's profile. Getting a fair daily ranking out of that means reconstructing each member's solve history and timestamping it against when the question was set.
There is a second problem underneath the first. recentAcSubmissionList returns the last 500 accepted
submissions and nothing before them. For an active member that is a sliding window measured in weeks,
so a solve that matters for the monthly board can scroll out of the only view LeetCode offers. The
answer is to treat the scrape as an increment rather than a snapshot: poll often, and merge what comes
back into a per-user dictionary that only ever grows. total_solved_dict on the member row is that
accumulator, and it is what makes a thirty-day window answerable from a five-hundred-submission feed.
How the scraping works
- Four GraphQL queries against
leetcode.com/graphql:userPublicProfilefor ranking, avatar and real name,recentAcSubmissionsfor the last 500 accepted submissions,languageStatsfor total solved by language, andproblemsetQuestionListpulling up to 5,000 questions to build atitleSlugtofrontendQuestionIdmap — because submissions come back by slug and questions are stored by id. Anything whose slug is not in that map is dropped rather than guessed at. - Deduplication by latest timestamp. A member may solve the same question repeatedly; only the most recent accepted submission per question id survives into the solved dictionary.
- The fairness rule.
match_questions_to_solvedkeeps a solve only when its timestamp is later than the question's posting date. Solving something you had already done years ago earns nothing. - Three rolling windows computed in one pass — 1 day, 1 week, 30 days — each written to a
LeaderboardEntrythat is unique on (user, interval). - Tie-breaking on speed. Rankings sort by
(-questions_solved, earliest_solved_timestamp), so two members on the same count are separated by who got there first. The field is filled with the maximum timestamp in the window, so what it actually holds is when you finished, and the ascending sort rewards finishing sooner. - Celery fan-out. A beat schedule fires
refresh_user_dataevery 120 seconds, which builds agroup()of oneget_user_datatask per member and chains it intocalculate_leaderboards, so every profile is fetched in parallel and the ranking runs exactly once after they all land. - Snapshot storage. Daily, weekly and monthly leaderboards are each persisted as a single JSON
blob via
update_or_create, so read traffic never touches the per-user tables. The same pass writes adaily_rank,weekly_rankandmonthly_rankback onto each member row, so a profile page can show a position without loading a board.
Four tables and a JSON column
Leetcode is the member: handle, real name, site ranking, avatar, three rank integers, and three
JSON dictionaries — the raw submissions, the accumulated solve history and the subset matched against
posted questions. Question is what the community set, with a LeetCode id, a slug, a posting date and
a difficulty drawn from Basic, Intermediate and Advanced rather than LeetCode's own three. LeaderboardEntry
is the computed count per member per window, and Leaderboard is the rendered snapshot.
Putting the solve history in a JSONField is the decision that carries the design. It means one row
per member instead of one row per solve, and it means the accumulator merge is a Python dict update
rather than a bulk upsert. What it costs is type discipline: JSON object keys are strings, so a
dictionary written with integer question ids comes back string-keyed after a round trip, and the two
halves of the pipeline do not agree about which they expect — the interval calculation looks up
str(question_id) while the matcher looks up the int. A relational solve table would have made that
impossible, at the price of a join on every window computation.
Logging in without a password
There is no password anywhere in the login path. The society's SSO issues an HS256 JWT, the client
hands it to /api/auth/login/, and a custom authentication backend decodes it against a shared
secret. If the email in the payload is unknown, a CUser is provisioned on the spot from the token's
email, name and roll number. Only then does DRF mint a token for the session. A first-time member gets
a 400 carrying leetcode: false, which the React client reads as "route to handle entry" rather than
as an error.
The awkward part is what happens next. Rather than creating the Leetcode row directly, the login
view makes an HTTP POST back to its own /api/leaderboard/register/ endpoint over loopback, with the
token it just issued. It reuses the registration endpoint's validation and its task dispatch for free,
and it means the service depends on being able to reach itself by address.
The rest of the stack
- Django REST with JSON-only rendering, DRF token authentication and a custom
CUsermodel keyed on email, with its own manager and SSO backend in accs_authapp. - Endpoints for registering a LeetCode handle, fetching your profile, today's questions, all questions, and the three leaderboards, plus login, logout and a manual refresh trigger.
- PostgreSQL with
JSONFieldcolumns holding the per-user submission, total-solved and matched-question dictionaries. - Redis as the Celery broker, with
django-celery-beat's database scheduler so the cadence is editable at runtime anddjango-celery-resultspersisting task outcomes. - Timezone pinned to Asia/Kolkata with
enable_utcoff, which matters because "solved today" has to mean the same thing to the scheduler and to the member looking at the board. - Docker Compose running Redis alongside the Django image on host port 4881.
- A Create React App client carried as a git submodule, with separate daily, weekly and monthly boards, a profile page, handle entry and a token-expiry check on route change.
Honest edges
The container ships the Django development server, not gunicorn — the gunicorn line in run.sh is
commented out, and DEBUG is on with ALLOWED_HOSTS at * and CORS open to everything. Compose
declares Redis and the web service but no Celery worker or beat container, so the schedule that drives
the whole thing has to be started by hand next to it. The manual refresh endpoint has no permission
class, so anyone who knows the path can trigger a full fan-out across every member. And the SSO
backend splits a display name on a single space into first and last, which is fine until somebody has
one name or three.
I came to this as a moderator on the community side and contributed to the codebase from there. The part I find worth pointing at is the tie-break: without it, a fifty-person leaderboard is mostly ties, and nobody looks at it twice.
Project Details
Moderator, contributor
Creative Computing Society
2024
Society · Backend