mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(cli): add SQLite cache, shared solution runner, and submit command
This commit is contained in:
+54
-52
@@ -1,70 +1,72 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
||||
* Problem index is fetched from leetcode.com and cached for 24h in .cache/.
|
||||
*
|
||||
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
|
||||
* most once per 24h; campaign problem descriptions from README.md are
|
||||
* backfilled into the same db on the way. Problems that already have a
|
||||
* numbered file under work/ are excluded from the picker — re-picking them
|
||||
* would only clobber the existing scaffold.
|
||||
*/
|
||||
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json");
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const WORK_DIR = join(ROOT, "work");
|
||||
|
||||
interface Problem {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
difficulty: "Easy" | "Medium" | "Hard";
|
||||
paidOnly: boolean;
|
||||
}
|
||||
const db = problemDb();
|
||||
|
||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||
|
||||
async function loadProblems(): Promise<Problem[]> {
|
||||
const file = Bun.file(CACHE_FILE);
|
||||
if (await file.exists()) {
|
||||
const stale = Date.now() - file.lastModified > CACHE_TTL_MS;
|
||||
if (!stale) return file.json();
|
||||
}
|
||||
// ── problem index (24h TTL) ──────────────────────────────────────
|
||||
|
||||
if (db.indexStale()) {
|
||||
const s = spinner();
|
||||
s.start("Fetching problem index from leetcode.com");
|
||||
const res = await fetch("https://leetcode.com/api/problems/all/", {
|
||||
headers: { "user-agent": "Mozilla/5.0" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
s.error(`Fetch failed: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
try {
|
||||
s.stop(`Indexed ${await db.refreshIndex()} problems`);
|
||||
} catch (err) {
|
||||
if (db.all().length === 0) {
|
||||
s.error(`Fetch failed: ${err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
s.stop(`Index refresh failed (${err}) — using cached copy`);
|
||||
}
|
||||
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;
|
||||
}>;
|
||||
};
|
||||
const problems: Problem[] = 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);
|
||||
s.stop(`Loaded ${problems.length} problems`);
|
||||
|
||||
await mkdir(join(ROOT, ".cache"), { recursive: true });
|
||||
await Bun.write(CACHE_FILE, JSON.stringify(problems));
|
||||
return problems;
|
||||
}
|
||||
|
||||
const problems = await loadProblems();
|
||||
// ── description backfill for the campaign problems in README.md ──
|
||||
|
||||
const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text()));
|
||||
if (missing.length > 0) {
|
||||
const s = spinner();
|
||||
s.start(`Caching ${missing.length} problem descriptions`);
|
||||
let fetched = 0;
|
||||
let failed = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, async () => {
|
||||
for (let slug = missing.shift(); slug; slug = missing.shift()) {
|
||||
try {
|
||||
const html = await fetchDescription(slug);
|
||||
if (html !== null) db.saveDescription(slug, html);
|
||||
s.message(`Caching problem descriptions (${++fetched})`);
|
||||
} catch {
|
||||
failed++; // left NULL — retried on the next run
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
|
||||
}
|
||||
|
||||
// ── exclude problems already picked or solved under work/ ────────
|
||||
|
||||
const done = new Set<number>();
|
||||
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
||||
const m = basename(rel).match(/^(\d+)\./);
|
||||
if (m) done.add(Number(m[1]));
|
||||
}
|
||||
|
||||
const problems = db.all().filter((p) => !done.has(p.id));
|
||||
db.close();
|
||||
|
||||
const picked = await autocomplete<Problem>({
|
||||
message: "Pick a problem",
|
||||
|
||||
Reference in New Issue
Block a user