|
|
@@ -0,0 +1,116 @@
|
|
|
+import fs from "node:fs/promises";
|
|
|
+import path from "node:path";
|
|
|
+import { spawn } from "node:child_process";
|
|
|
+
|
|
|
+const CHANNEL_VIDEOS_URL = "https://www.youtube.com/@simonlol/videos";
|
|
|
+const OUT_DIR = path.join(process.cwd(), "src", "content", "blogs");
|
|
|
+
|
|
|
+function run(cmd, args) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
|
+ let out = "";
|
|
|
+ let err = "";
|
|
|
+ child.stdout.on("data", (d) => (out += d.toString("utf8")));
|
|
|
+ child.stderr.on("data", (d) => (err += d.toString("utf8")));
|
|
|
+ child.on("error", reject);
|
|
|
+ child.on("close", (code) => {
|
|
|
+ if (code === 0) resolve({ out, err });
|
|
|
+ else reject(new Error(`${cmd} exited with ${code}\n${err}`));
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function escYaml(s) {
|
|
|
+ return String(s).replaceAll('"', "\\\"");
|
|
|
+}
|
|
|
+
|
|
|
+function videoMd({ id, title, uploadDate, viewCount }) {
|
|
|
+ const safeTitle = title?.trim() || id;
|
|
|
+ // yt-dlp gives upload_date as YYYYMMDD.
|
|
|
+ const pubDate = uploadDate && /^\d{8}$/.test(uploadDate)
|
|
|
+ ? `${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}`
|
|
|
+ : new Date().toISOString().slice(0, 10);
|
|
|
+ return `---\n` +
|
|
|
+ `title: "${escYaml(safeTitle)}"\n` +
|
|
|
+ `pubDate: ${pubDate}\n` +
|
|
|
+ `views: ${Number.isFinite(Number(viewCount)) ? Number(viewCount) : 0}\n` +
|
|
|
+ `description: "YouTube video"\n` +
|
|
|
+ `---\n\n` +
|
|
|
+ `<div style="position:relative; width:100%; aspect-ratio:16/9; background:#000;">\n` +
|
|
|
+ ` <iframe\n` +
|
|
|
+ ` title="${safeTitle.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")}"\n` +
|
|
|
+ ` src="https://www.youtube-nocookie.com/embed/${id}?rel=0"\n` +
|
|
|
+ ` style="position:absolute; inset:0; width:100%; height:100%; border:0;"\n` +
|
|
|
+ ` allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"\n` +
|
|
|
+ ` allowfullscreen\n` +
|
|
|
+ ` referrerpolicy="strict-origin-when-cross-origin"\n` +
|
|
|
+ ` ></iframe>\n` +
|
|
|
+ `</div>\n`;
|
|
|
+}
|
|
|
+
|
|
|
+async function main() {
|
|
|
+ // Ensure output dir exists
|
|
|
+ await fs.mkdir(OUT_DIR, { recursive: true });
|
|
|
+
|
|
|
+ // Fetch list via yt-dlp.
|
|
|
+ // Note: `--flat-playlist` does not include `upload_date` (it returns NA).
|
|
|
+ // We fetch ids+titles flat, then fetch upload_date per video id.
|
|
|
+ const { out } = await run("yt-dlp", [
|
|
|
+ "--flat-playlist",
|
|
|
+ "--print",
|
|
|
+ "%(id)s\t%(title)s",
|
|
|
+ CHANNEL_VIDEOS_URL,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const lines = out
|
|
|
+ .split("\n")
|
|
|
+ .map((l) => l.trim())
|
|
|
+ .filter(Boolean);
|
|
|
+
|
|
|
+ const videos = lines
|
|
|
+ .map((l) => {
|
|
|
+ const [id, ...rest] = l.split("\t");
|
|
|
+ const title = rest.join("\t");
|
|
|
+ return { id, title };
|
|
|
+ })
|
|
|
+ .filter((v) => v.id && v.id !== "NA");
|
|
|
+
|
|
|
+ for (const v of videos) {
|
|
|
+ try {
|
|
|
+ const { out: metaOut } = await run("yt-dlp", [
|
|
|
+ "--no-playlist",
|
|
|
+ "--print",
|
|
|
+ "%(upload_date)s\t%(view_count)s",
|
|
|
+ `https://www.youtube.com/watch?v=${v.id}`,
|
|
|
+ ]);
|
|
|
+ const first = metaOut.trim().split("\n")[0] || "";
|
|
|
+ const [uploadDate = "", viewCount = ""] = first.split("\t");
|
|
|
+ v.uploadDate = uploadDate;
|
|
|
+ v.viewCount = viewCount;
|
|
|
+ } catch {
|
|
|
+ v.uploadDate = "";
|
|
|
+ v.viewCount = "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Read existing generated files
|
|
|
+ const existing = await fs.readdir(OUT_DIR);
|
|
|
+ const existingGenerated = new Set(existing.filter((f) => f.startsWith("video-") && f.endsWith(".md")));
|
|
|
+
|
|
|
+ // Write/update current
|
|
|
+ for (const v of videos) {
|
|
|
+ const fileName = `video-${v.id}.md`;
|
|
|
+ existingGenerated.delete(fileName);
|
|
|
+ await fs.writeFile(path.join(OUT_DIR, fileName), videoMd(v), "utf8");
|
|
|
+ }
|
|
|
+
|
|
|
+ // Remove stale generated files
|
|
|
+ for (const stale of existingGenerated) {
|
|
|
+ await fs.unlink(path.join(OUT_DIR, stale));
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+main().catch((e) => {
|
|
|
+ console.error("[sync-youtube-videos]", e?.message || e);
|
|
|
+ process.exit(1);
|
|
|
+});
|