Files
leetcode/apps/cli/db.ts
T
root 7249da8bd6 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.
2026-11-02 12:56:51 -05:00

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();
},
};
}