2026-09-21 15:53:15 -04:00
|
|
|
#!/usr/bin/env bun
|
|
|
|
|
/**
|
|
|
|
|
* Find the NeetCode explanation video for every docs page that has none.
|
|
|
|
|
*
|
2026-09-28 11:19:32 -04:00
|
|
|
* A finished solution page carries frontmatter `video: <YouTube id>` —
|
2026-09-29 17:03:25 -04:00
|
|
|
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
|
2026-09-28 11:19:32 -04:00
|
|
|
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
|
|
|
|
|
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
|
|
|
|
|
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
|
|
|
|
|
* one. This script lists every numbered page lacking `video:` and looks each
|
|
|
|
|
* up on NeetCode's two channels, main channel first, accepting only a title
|
|
|
|
|
* that names the page's exact LC number (`… - Leetcode 226 - …`) and is not a
|
|
|
|
|
* #short. No YouTube API key: the channel search page embeds its results as
|
|
|
|
|
* `ytInitialData` JSON, which is all we parse. Ranking is the channel's own —
|
|
|
|
|
* the first exact-number match wins.
|
2026-09-21 15:53:15 -04:00
|
|
|
*
|
|
|
|
|
* bun apps/cli/videos.ts # report: page → video (or "no match")
|
2026-09-28 11:19:32 -04:00
|
|
|
* bun apps/cli/videos.ts --write # also add `video:` to the frontmatter
|
2026-09-21 15:53:15 -04:00
|
|
|
*
|
2026-09-28 11:19:32 -04:00
|
|
|
* --write inserts the key before `sidebar:`. A page with no match is left
|
|
|
|
|
* untouched, so re-runs are no-ops once every page has its video.
|
2026-09-21 15:53:15 -04:00
|
|
|
*/
|
|
|
|
|
import { join } from "node:path";
|
|
|
|
|
|
|
|
|
|
import { printTable } from "./report.ts";
|
|
|
|
|
import { ROOT } from "./work.ts";
|
|
|
|
|
|
|
|
|
|
const DOCS = join(ROOT, "apps", "docs", "content");
|
|
|
|
|
const WRITE = process.argv.includes("--write");
|
|
|
|
|
|
|
|
|
|
/** Channel handles searched in order; the first exact match wins. */
|
|
|
|
|
const CHANNELS = ["NeetCode", "NeetCodeIO"] as const;
|
|
|
|
|
|
|
|
|
|
interface Video {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
channel: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── YouTube channel search (no API key) ──────────────────────────
|
|
|
|
|
|
|
|
|
|
const HEADERS = { "accept-language": "en-US,en;q=0.9", "user-agent": "Mozilla/5.0" };
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The `var ytInitialData = {…};</script>` 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<unknown> {
|
|
|
|
|
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("</script>", 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<Video[]> {
|
|
|
|
|
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<Video | undefined> {
|
|
|
|
|
for (const channel of CHANNELS) {
|
|
|
|
|
const hit = (await channelSearch(channel, `leetcode ${num}`)).find(
|
|
|
|
|
(v) => titledNumber(v.title) === num && !/#shorts\b/i.test(v.title),
|
|
|
|
|
);
|
|
|
|
|
if (hit) return hit;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── docs pages ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface Page {
|
|
|
|
|
num: number;
|
|
|
|
|
rel: string;
|
|
|
|
|
text: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 11:19:32 -04:00
|
|
|
/** The page's frontmatter block, `---` to `---`. */
|
|
|
|
|
const FRONTMATTER = /^---\n[\s\S]*?\n---/;
|
|
|
|
|
|
|
|
|
|
/** Numbered solution pages without a `video:` key, by LC number. */
|
2026-09-21 15:53:15 -04:00
|
|
|
async function pagesMissingVideo(): Promise<Page[]> {
|
|
|
|
|
const pages: Page[] = [];
|
|
|
|
|
for (const rel of await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))) {
|
|
|
|
|
const text = await Bun.file(join(DOCS, rel)).text();
|
|
|
|
|
const t = text.match(/^title: '(\d+)\./m);
|
2026-09-28 11:19:32 -04:00
|
|
|
const fm = text.match(FRONTMATTER)?.[0] ?? "";
|
|
|
|
|
if (t && !/^video:/m.test(fm)) pages.push({ num: Number(t[1]), rel, text });
|
2026-09-21 15:53:15 -04:00
|
|
|
}
|
|
|
|
|
return pages.sort((a, b) => a.num - b.num);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── main ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const missing = await pagesMissingVideo();
|
|
|
|
|
if (missing.length === 0) {
|
|
|
|
|
console.log("every solution page has a video");
|
|
|
|
|
process.exit(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rows: string[][] = [["docs page", "video", "title", WRITE ? "status" : ""]];
|
|
|
|
|
let found = 0;
|
|
|
|
|
for (const page of missing) {
|
|
|
|
|
const video = await findVideo(page.num);
|
|
|
|
|
if (!video) {
|
|
|
|
|
rows.push([page.rel, "—", "no NeetCode match", WRITE ? "skipped" : ""]);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
found++;
|
|
|
|
|
let status = "";
|
|
|
|
|
if (WRITE) {
|
2026-09-28 11:19:32 -04:00
|
|
|
const next = page.text.replace(FRONTMATTER, (fm) => fm.replace(/^sidebar:$/m, `video: ${JSON.stringify(video.id)}\nsidebar:`));
|
|
|
|
|
await Bun.write(join(DOCS, page.rel), next);
|
2026-09-21 15:53:15 -04:00
|
|
|
status = "written";
|
|
|
|
|
}
|
|
|
|
|
rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!WRITE) for (const row of rows) row.pop();
|
|
|
|
|
printTable(rows);
|
|
|
|
|
console.log(`\n${found}/${missing.length} page(s) matched${WRITE ? `, ${found} written` : ""}`);
|