mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-03 05:27:36 +00:00
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.
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
/**
|
|
* Local LeetCode problem cache backed by SQLite at
|
|
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
|
|
*
|
|
* One `problems` table, two freshness rules:
|
|
* - the full problem index (id / title / slug / difficulty / paid flag) is
|
|
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
|
|
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
|
|
* 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.
|
|
* 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
|
|
* a daily refresh cannot evict cached statements.
|
|
*/
|
|
import { Database } from "bun:sqlite";
|
|
import { mkdirSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
|
|
export interface Problem {
|
|
id: number;
|
|
title: string;
|
|
slug: string;
|
|
difficulty: "Easy" | "Medium" | "Hard";
|
|
paidOnly: boolean;
|
|
}
|
|
|
|
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
|
export interface SolutionActivity {
|
|
createdAt: string;
|
|
lastTested: string | null;
|
|
lastSubmitted: string | null;
|
|
}
|
|
|
|
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"),
|
|
"leetcode",
|
|
"data.db",
|
|
);
|
|
|
|
// ── README parsing ───────────────────────────────────────────────
|
|
|
|
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
|
|
export function readmeSlugs(markdown: string): string[] {
|
|
const slugs = new Set<string>();
|
|
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
|
|
slugs.add(m[1]!);
|
|
}
|
|
return [...slugs];
|
|
}
|
|
|
|
// ── remote fetches ───────────────────────────────────────────────
|
|
|
|
/** leetcode-cli's login: the two cookies every signed-in request carries. */
|
|
export interface Credentials {
|
|
session: string;
|
|
csrfToken: string;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
...(auth && {
|
|
cookie: `LEETCODE_SESSION=${auth.session}; csrftoken=${auth.csrfToken}`,
|
|
"x-csrftoken": auth.csrfToken,
|
|
}),
|
|
},
|
|
body: JSON.stringify({ query, variables }),
|
|
});
|
|
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 ────────────────────────────────────────────────────
|
|
|
|
export function problemDb(path = DB_PATH) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const db = new Database(path);
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS problems (
|
|
id INTEGER PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
difficulty TEXT NOT NULL,
|
|
paid_only INTEGER NOT NULL,
|
|
description TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS solutions (
|
|
id INTEGER PRIMARY KEY,
|
|
created_at TEXT NOT NULL,
|
|
last_tested TEXT,
|
|
last_submitted TEXT
|
|
);
|
|
DROP TABLE IF EXISTS submissions;
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
|
|
const setMeta = db.query<never, [string, string]>(
|
|
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
);
|
|
const upsert = db.query<never, [number, string, string, string, number]>(
|
|
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
title = excluded.title, slug = excluded.slug,
|
|
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
|
|
);
|
|
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 {
|
|
return stale("index_fetched_at");
|
|
},
|
|
|
|
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
|
|
async refreshIndex(): Promise<number> {
|
|
const problems = await fetchIndex();
|
|
db.transaction(() => {
|
|
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
|
|
setMeta.run("index_fetched_at", String(Date.now()));
|
|
})();
|
|
return problems.length;
|
|
},
|
|
|
|
/** Every cached problem, ordered by LC number. */
|
|
all(): Problem[] {
|
|
const rows = db
|
|
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
|
|
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
|
|
)
|
|
.all();
|
|
return rows.map((r) => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
slug: r.slug,
|
|
difficulty: r.difficulty as Problem["difficulty"],
|
|
paidOnly: r.paid_only === 1,
|
|
}));
|
|
},
|
|
|
|
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
|
|
missingDescriptions(slugs: string[]): string[] {
|
|
return slugs.filter((slug) => {
|
|
const row = descState.get(slug);
|
|
return row !== null && !row.paid_only && !row.described;
|
|
});
|
|
},
|
|
|
|
saveDescription(slug: string, html: string): void {
|
|
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
|
},
|
|
|
|
/** Per-solution lifecycle rows keyed by LC number. */
|
|
solutionActivity(): Map<number, SolutionActivity> {
|
|
const rows = db
|
|
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
|
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
|
)
|
|
.all();
|
|
return new Map(
|
|
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
|
);
|
|
},
|
|
|
|
/** Register a solution's creation time; existing rows are left alone. */
|
|
ensureSolution(id: number, createdAt: string): void {
|
|
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
|
},
|
|
|
|
markTested(id: number): void {
|
|
db.query(
|
|
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
|
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
|
).run(id, new Date().toISOString());
|
|
},
|
|
|
|
markSubmitted(id: number): void {
|
|
db.query(
|
|
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
|
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
|
).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();
|
|
},
|
|
};
|
|
}
|