mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
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:
1 parent
83cb284e87
commit
7249da8bd6
3 files changed
+259
-45
No files matched your search
+219
-41
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user