fix(cli): move LeetCode fetches to GraphQL and restore history from the profile

LeetCode's REST /api/* endpoints now answer a Cloudflare 403 to non-browser
clients, which broke the picker's problem index on an empty cache. The index
and descriptions now come from /graphql, and test/submit fold the signed-in
profile's submission history into the solutions table once a day, so a lost
db or a fresh clone recovers both orderings.
This commit is contained in:
root committed 2026-11-02 12:56:51 -05:00
1 parent 83cb284e87
commit 7249da8bd6
3 files changed
+259 -45

No files matched your search

+5 -1
View File
@@ -71,7 +71,11 @@ bun run test # run ONE solution against LeetCode's judge (not a test
# files git sees as modified/untracked first, so Enter runs the
# one in progress; then most recently tested; stamped on exit 0)
bun run submit # submit ONE solution to LeetCode (uncommitted first, then newest
# scaffold, nothing hidden; last_submitted stamped on exit 0)
# scaffold, nothing hidden; last_submitted stamped on exit 0).
# Both fold in the signed-in profile's submission history once
# a day (leetcode-cli's keychain login; GraphQL only — LeetCode's
# REST /api/* answers 403), so a lost db or fresh clone recovers
# both orderings
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
# links; re-solves are never published)
bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page
+219 -41
View File
@@ -9,12 +9,20 @@
* slug and kept forever. Only the campaign problems linked from README.md
* are ever fetched, and paid-only problems are skipped because GraphQL
* returns null content for them.
* Everything goes through /graphql: Cloudflare challenges the REST endpoints
* (`/api/problems/all/`, `/api/submissions/`) with a 403 for any non-browser
* client, signed in or not.
*
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
* `created_at` (backfilled from the file's birthtime on every picker run),
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
* The test picker sorts by last_tested, the submit picker by created_at, and
* submit hides anything with a last_submitted.
* The test picker sorts by last_tested, the submit picker by created_at.
* Local stamps die with the db and birthtimes with a clone, so at most once
* per 24h (`meta.history_fetched_at`) the signed-in profile's submission
* history is folded in: a problem's first submission caps `created_at`, its
* latest raises `last_submitted` and `last_tested` (a submission is a judge
* run too; LeetCode keeps no record of `leetcode test` runs). Folding only
* ever moves a timestamp outward, so a re-run is a no-op.
*
* Library: side-effect-free on import — nothing is opened or fetched until
* problemDb() is called. Index upserts never touch the description column, so
@@ -40,8 +48,8 @@ export interface SolutionActivity {
lastSubmitted: string | null;
}
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
const DIFFICULTY = { EASY: "Easy", MEDIUM: "Medium", HARD: "Hard" } as const;
const TTL_MS = 24 * 60 * 60 * 1000;
export const DB_PATH = join(
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
@@ -62,50 +70,185 @@ export function readmeSlugs(markdown: string): string[] {
// ── remote fetches ───────────────────────────────────────────────
async function fetchIndex(): Promise<Problem[]> {
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
stat_status_pairs: Array<{
stat: {
frontend_question_id: number;
question__title: string;
question__title_slug: string;
};
difficulty: { level: 1 | 2 | 3 };
paid_only: boolean;
}>;
};
return data.stat_status_pairs
.map((p) => ({
id: p.stat.frontend_question_id,
title: p.stat.question__title,
slug: p.stat.question__title_slug,
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
paidOnly: p.paid_only,
}))
.sort((a, b) => a.id - b.id);
/** leetcode-cli's login: the two cookies every signed-in request carries. */
export interface Credentials {
session: string;
csrfToken: string;
}
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
export async function fetchDescription(slug: string): Promise<string | null> {
/**
* POST one query to leetcode.com/graphql. Throws on an HTTP error or a
* response with no `data`; partial `errors` beside data are the caller's.
*/
async function graphql<T>(
query: string,
variables: Record<string, unknown>,
{ referer = "https://leetcode.com/", auth }: { referer?: string; auth?: Credentials } = {},
): Promise<T> {
const res = await fetch("https://leetcode.com/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "Mozilla/5.0",
referer: `https://leetcode.com/problems/${slug}/`,
referer,
...(auth && {
cookie: `LEETCODE_SESSION=${auth.session}; csrftoken=${auth.csrfToken}`,
"x-csrftoken": auth.csrfToken,
}),
},
body: JSON.stringify({
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
variables: { titleSlug: slug },
}),
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
return data.data?.question?.content ?? null;
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const body = (await res.json()) as { data?: T | null; errors?: { message: string }[] };
if (!body.data) {
throw new Error(`POST /graphql: ${body.errors?.map((e) => e.message).join("; ") || "no data"}`);
}
return body.data;
}
/** Server-side cap on `limit` for the problem list. */
const INDEX_PAGE = 100;
async function fetchIndex(): Promise<Problem[]> {
type Page = {
problemsetQuestionListV2: {
totalLength: number;
questions: Array<{
questionFrontendId: string;
title: string;
titleSlug: string;
difficulty: keyof typeof DIFFICULTY;
paidOnly: boolean;
}>;
};
};
const page = async (skip: number) =>
(
await graphql<Page>(
`query problemIndex($skip: Int!, $limit: Int!) {
problemsetQuestionListV2(skip: $skip, limit: $limit) {
totalLength
questions { questionFrontendId title titleSlug difficulty paidOnly }
}
}`,
{ skip, limit: INDEX_PAGE },
{ referer: "https://leetcode.com/problemset/" },
)
).problemsetQuestionListV2;
// The first page names the total; the rest go six at a time.
const first = await page(0);
const questions = [...first.questions];
const skips = Array.from({ length: Math.ceil(first.totalLength / INDEX_PAGE) - 1 }, (_, i) => (i + 1) * INDEX_PAGE);
await Promise.all(
Array.from({ length: 6 }, async () => {
for (let skip = skips.shift(); skip !== undefined; skip = skips.shift()) {
questions.push(...(await page(skip)).questions);
}
}),
);
if (questions.length < first.totalLength) {
throw new Error(`problem index came back short: ${questions.length} of ${first.totalLength}`);
}
return questions
.map((q) => ({
id: Number(q.questionFrontendId),
title: q.title,
slug: q.titleSlug,
difficulty: DIFFICULTY[q.difficulty],
paidOnly: q.paidOnly,
}))
.filter((p) => Number.isInteger(p.id))
.sort((a, b) => a.id - b.id);
}
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
export async function fetchDescription(slug: string): Promise<string | null> {
const data = await graphql<{ question?: { content?: string | null } | null }>(
"query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
{ titleSlug: slug },
{ referer: `https://leetcode.com/problems/${slug}/` },
);
return data.question?.content ?? null;
}
/**
* The login leetcode-cli holds, read from where it keeps it: its own
* LEETCODE_SESSION + LEETCODE_CSRF_TOKEN override, else the OS keychain entry
* `leetcode login` writes through keytar (service `leetcode-cli`, account
* `auth`). Null when signed out, or when the login lives in leetcode-cli's
* encrypted-file fallback, whose key is the CLI's own.
*/
export function leetcodeCredentials(): Credentials | null {
const { LEETCODE_SESSION: session, LEETCODE_CSRF_TOKEN: csrfToken } = process.env;
if (session && csrfToken) return { session, csrfToken };
const lookup =
process.platform === "darwin"
? ["security", "find-generic-password", "-s", "leetcode-cli", "-a", "auth", "-w"]
: ["secret-tool", "lookup", "service", "leetcode-cli", "account", "auth"];
let stored: string;
try {
const out = Bun.spawnSync(lookup, { stderr: "ignore" });
if (!out.success) return null;
stored = out.stdout.toString();
} catch {
return null; // no keychain CLI on this machine
}
let parsed: unknown;
try {
parsed = JSON.parse(stored);
} catch {
return null;
}
if (
!parsed ||
typeof parsed !== "object" ||
!("session" in parsed) ||
typeof parsed.session !== "string" ||
!("csrfToken" in parsed) ||
typeof parsed.csrfToken !== "string"
) {
return null;
}
return { session: parsed.session, csrfToken: parsed.csrfToken };
}
/** One submission of the signed-in profile, any verdict. */
export interface Submission {
slug: string;
/** ISO timestamp. */
at: string;
}
/** Server-side cap on `limit` for the submission list. */
const SUBMISSION_PAGE = 20;
/** The signed-in profile's whole submission history, newest first. */
export async function fetchSubmissions(auth: Credentials): Promise<Submission[]> {
type Page = {
submissionList: { hasNext: boolean; submissions: Array<{ titleSlug: string; timestamp: string }> } | null;
};
const all: Submission[] = [];
for (let offset = 0; ; offset += SUBMISSION_PAGE) {
const { submissionList } = await graphql<Page>(
`query submissionList($offset: Int!, $limit: Int!) {
submissionList(offset: $offset, limit: $limit) {
hasNext
submissions { titleSlug timestamp }
}
}`,
{ offset, limit: SUBMISSION_PAGE },
{ referer: "https://leetcode.com/submissions/", auth },
);
// An expired session is answered with a null list, not an HTTP error.
if (!submissionList) throw new Error("submission history unavailable — session expired? run `leetcode login`");
for (const s of submissionList.submissions) {
all.push({ slug: s.titleSlug, at: new Date(Number(s.timestamp) * 1000).toISOString() });
}
if (!submissionList.hasNext) return all;
}
}
// ── the cache ────────────────────────────────────────────────────
@@ -149,14 +292,28 @@ export function problemDb(path = DB_PATH) {
const descState = db.query<{ paid_only: number; described: number }, [string]>(
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
);
// MIN/MAX on TEXT compare ISO timestamps chronologically; COALESCE because
// SQLite's scalar MAX returns NULL when any argument is NULL.
const fold = db.query<never, [number, string, string]>(
`UPDATE solutions SET
created_at = MIN(created_at, ?2),
last_tested = MAX(COALESCE(last_tested, ''), ?3),
last_submitted = MAX(COALESCE(last_submitted, ''), ?3)
WHERE id = ?1`,
);
/** True when `key` has never been stamped or was stamped over 24h ago. */
const stale = (key: string) => {
const row = getMeta.get(key);
return !row || Date.now() - Number(row.value) > TTL_MS;
};
return {
path,
/** True when the index has never been fetched or is older than 24h. */
indexStale(): boolean {
const row = getMeta.get("index_fetched_at");
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
return stale("index_fetched_at");
},
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
@@ -228,6 +385,27 @@ export function problemDb(path = DB_PATH) {
).run(id, new Date().toISOString());
},
/** True when submission history has never been folded in or over 24h ago. */
historyStale(): boolean {
return stale("history_fetched_at");
},
/**
* Fold the profile's submissions (LC number → first and latest submission,
* ISO) into existing solution rows and stamp the refresh. Rows are never
* created here — the table holds work/ files only — and a timestamp only
* ever moves outward, so folding twice changes nothing. Returns the number
* of solutions that have submissions.
*/
foldHistory(history: Map<number, { first: string; last: string }>): number {
let matched = 0;
db.transaction(() => {
for (const [id, { first, last }] of history) matched += fold.run(id, first, last).changes;
setMeta.run("history_fetched_at", String(Date.now()));
})();
return matched;
},
close(): void {
db.close();
},
+35 -3
View File
@@ -9,7 +9,9 @@
*
* Every run backfills the db's solutions table with created_at (from the
* file's birthtime, mtime when the filesystem has none) so ordering works for
* files that predate the db. Files git reports as modified, added or
* files that predate the db, and at most once a day folds in the signed-in
* LeetCode profile's submission history (see db.ts), which survives a lost db
* or a fresh clone. Files git reports as modified, added or
* untracked come first, newest edit first — the problem being worked on is
* the focused option, so Enter runs it without a search. The rest are ordered
* per command — test lists the most recently tested first (never-tested
@@ -19,10 +21,10 @@
* Library: side-effect-free on import — nothing scans the filesystem until
* runOnSolution() is called.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
import { statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { problemDb } from "./db";
import { fetchSubmissions, leetcodeCredentials, problemDb } from "./db";
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
@@ -77,6 +79,36 @@ export async function runOnSolution(
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
);
}
// the profile's history: first and latest submission per work/ problem
if (db.historyStale()) {
const auth = leetcodeCredentials();
if (!auth) {
log.warn("Not signed in to leetcode-cli — run `leetcode login` to restore history from your profile.");
} else {
const s = spinner();
s.start("Syncing submission history from your LeetCode profile");
try {
const bySlug = new Map<string, number>();
for (const f of scanned) {
const parsed = parseWorkPath(f);
if (parsed) bySlug.set(parsed.slug, parsed.lc);
}
const history = new Map<number, { first: string; last: string }>();
for (const { slug, at } of await fetchSubmissions(auth)) {
const id = bySlug.get(slug);
if (id === undefined) continue;
const seen = history.get(id);
if (!seen) history.set(id, { first: at, last: at });
else if (at < seen.first) seen.first = at;
else if (at > seen.last) seen.last = at;
}
s.stop(`Synced submission history for ${db.foldHistory(history)} solutions`);
} catch (err) {
s.error(`Submission history sync failed (${err}) — using cached timestamps`);
}
}
}
const activity = db.solutionActivity();
// test → most recently tested first; submit → most recently created first