diff --git a/AGENTS.md b/AGENTS.md index 2e35cd5..1b5c95e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (/ short # links; re-solves are never published) bun run videos # docs pages with no `video:` frontmatter → NeetCode video per page diff --git a/apps/cli/db.ts b/apps/cli/db.ts index c520ea4..dbb9237 100644 --- a/apps/cli/db.ts +++ b/apps/cli/db.ts @@ -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 { - 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 { +/** + * 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: `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 { + 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 ──────────────────────────────────────────────────── @@ -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( + `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 { + 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(); }, diff --git a/apps/cli/solutions.ts b/apps/cli/solutions.ts index fe52dae..6aeafcb 100644 --- a/apps/cli/solutions.ts +++ b/apps/cli/solutions.ts @@ -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(); + for (const f of scanned) { + const parsed = parseWorkPath(f); + if (parsed) bySlug.set(parsed.slug, parsed.lc); + } + const history = new Map(); + 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