Codeboard
Completed
Society · Backend

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.

Built with
DjangoDjango
Django REST FrameworkDjango REST Framework
CeleryCelery
RedisRedis
PostgreSQLPostgreSQL
ReactReact
DockerDocker
Project Details
RESOURCES

STATUS
Completed
ROLE

Moderator, contributor

ORGANISATION

Creative Computing Society

YEAR

2024

TYPE

Society · Backend

TAGS
Leaderboard
Scraping
Automation

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: userPublicProfile for ranking, avatar and real name, recentAcSubmissions for the last 500 accepted submissions, languageStats for total solved by language, and problemsetQuestionList pulling up to 5,000 questions to build a titleSlug to frontendQuestionId map — 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_solved keeps 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 LeaderboardEntry that 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_data every 120 seconds, which builds a group() of one get_user_data task per member and chains it into calculate_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 a daily_rank, weekly_rank and monthly_rank back 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 CUser model keyed on email, with its own manager and SSO backend in a ccs_auth app.
  • 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 JSONField columns 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 and django-celery-results persisting task outcomes.
  • Timezone pinned to Asia/Kolkata with enable_utc off, 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
RESOURCES

STATUS
Completed
ROLE

Moderator, contributor

ORGANISATION

Creative Computing Society

YEAR

2024

TYPE

Society · Backend

TAGS
Leaderboard
Scraping
Automation