/** * 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(); 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( query: string, variables: Record, { referer = "https://leetcode.com/", auth }: { referer?: string; auth?: Credentials } = {}, ): Promise { 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 { 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( `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 { 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 { 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( `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( "INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", ); const upsert = db.query( `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( `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 { 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 { 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 { 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(); }, }; }