diff --git a/apps/cli/package.json b/apps/cli/package.json index f437570..a3c2985 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -8,12 +8,13 @@ "test": "bun ./test.ts", "submit": "bun ./submit.ts", "sync": "bun ./sync.ts", + "videos": "bun ./videos.ts", "close-solved": "bun ./close-solved.ts", "close-topics": "bun ./close-topics.ts" }, "devDependencies": { "@clack/prompts": "^1.7.0", - "@night-slayer18/leetcode-cli": "^3.5.0", + "@night-slayer18/leetcode-cli": "^3.5.2", "@types/bun": "^1.3.14" } } diff --git a/apps/cli/solutions.ts b/apps/cli/solutions.ts index 97fedc7..66f6561 100644 --- a/apps/cli/solutions.ts +++ b/apps/cli/solutions.ts @@ -30,17 +30,27 @@ function lcNumber(rel: string): number | undefined { } /** Fuzzy-pick a solution under work/ and run `leetcode ` on it. */ -export async function runOnSolution(command: "test" | "submit", message: string): Promise { +export async function runOnSolution( + command: "test" | "submit", + message: string, +): Promise { const db = problemDb(); - const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]; + const scanned = [ + ...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync( + { cwd: WORK_DIR }, + ), + ]; // register creation times so files older than the db still sort correctly for (const f of scanned) { const num = lcNumber(f); if (num === undefined) continue; const stat = statSync(join(WORK_DIR, f)); - db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString()); + db.ensureSolution( + num, + new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(), + ); } const activity = db.solutionActivity(); @@ -51,20 +61,21 @@ export async function runOnSolution(command: "test" | "submit", message: string) return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined; }; - const files = scanned - .sort((a, b) => { - const ka = sortKey(a); - const kb = sortKey(b); - if (ka !== kb) { - if (ka === undefined) return 1; - if (kb === undefined) return -1; - return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological - } - return a.localeCompare(b, undefined, { numeric: true }); - }); + const files = scanned.sort((a, b) => { + const ka = sortKey(a); + const kb = sortKey(b); + if (ka !== kb) { + if (ka === undefined) return 1; + if (kb === undefined) return -1; + return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological + } + return a.localeCompare(b, undefined, { numeric: true }); + }); if (files.length === 0) { - log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`); + log.error( + `No solution files found in ${relative(process.cwd(), WORK_DIR)}/`, + ); process.exit(1); } @@ -74,11 +85,14 @@ export async function runOnSolution(command: "test" | "submit", message: string) maxItems: 12, options: files.map((f) => { const parsed = parseWorkPath(f); - const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`; + const stage = + parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`; return { value: f, label: basename(f), - hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f, + hint: parsed + ? `${stage} · ${parsed.difficulty} · ${parsed.category}` + : f, }; }), filter: (search, option) => { diff --git a/apps/cli/videos.ts b/apps/cli/videos.ts new file mode 100755 index 0000000..39282d9 --- /dev/null +++ b/apps/cli/videos.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env bun +/** + * Find the NeetCode explanation video for every docs page that has none. + * + * A finished solution page ends with `## Explanation` holding one + * `` — NeetCode's walkthrough of the problem. + * `sync.ts` never writes that section (it owns only `## Solution`), so pages + * accumulate without one. This script lists every numbered page lacking a + * `` blob of a YouTube page, parsed. + * YouTube intermittently answers 200 with a page that lacks the blob or cuts + * it short, so a bad page is retried a few times before it is an error. + */ +async function initialData(url: string): Promise { + let reason = ""; + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt) await Bun.sleep(500 * attempt); + const res = await fetch(url, { headers: HEADERS }); + if (!res.ok) { + reason = `status ${res.status}`; + continue; + } + const html = await res.text(); + // Slice rather than regex: the blob is ~1 MB, and a lazy `.*?` across it + // is what made the first cut of this script fail at random. + const open = html.search(/ytInitialData\s*=\s*\{/); + const close = open === -1 ? -1 : html.indexOf("", open); + if (close === -1) { + reason = `no ytInitialData in ${html.length}-byte page`; + continue; + } + try { + return JSON.parse(html.slice(html.indexOf("{", open), close).replace(/;\s*$/, "")); + } catch { + reason = "truncated ytInitialData"; + } + } + throw new Error(`GET ${url} -> ${reason}`); +} + +/** Every videoRenderer in a channel search page, in the page's own order. */ +async function channelSearch(channel: string, query: string): Promise { + const url = `https://www.youtube.com/@${channel}/search?query=${encodeURIComponent(query)}`; + const data = await initialData(url); + + const videos: Video[] = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== "object") return; + if ("videoRenderer" in node) { + const v = node.videoRenderer; + if (v && typeof v === "object" && "videoId" in v && typeof v.videoId === "string") { + const runs = + "title" in v && v.title && typeof v.title === "object" && "runs" in v.title + ? v.title.runs + : undefined; + const title = Array.isArray(runs) + ? runs.map((r) => (r && typeof r === "object" && "text" in r ? String(r.text) : "")).join("") + : ""; + videos.push({ id: v.videoId, title, channel }); + } + return; + } + for (const child of Object.values(node)) walk(child); + }; + walk(data); + return videos; +} + +/** The LC number a NeetCode title names (`… - Leetcode 226 - …`), if any. */ +function titledNumber(title: string): number | undefined { + const m = title.match(/\bleetcode\s*#?\s*(\d+)\b/i); + return m ? Number(m[1]) : undefined; +} + +async function findVideo(num: number): Promise