sync-youtube-videos.mjs 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import fs from "node:fs/promises";
  2. import path from "node:path";
  3. import { spawn } from "node:child_process";
  4. const CHANNEL_VIDEOS_URL = "https://www.youtube.com/@simonlol/videos";
  5. const OUT_DIR = path.join(process.cwd(), "src", "content", "blogs");
  6. function run(cmd, args) {
  7. return new Promise((resolve, reject) => {
  8. const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
  9. let out = "";
  10. let err = "";
  11. child.stdout.on("data", (d) => (out += d.toString("utf8")));
  12. child.stderr.on("data", (d) => (err += d.toString("utf8")));
  13. child.on("error", reject);
  14. child.on("close", (code) => {
  15. if (code === 0) resolve({ out, err });
  16. else reject(new Error(`${cmd} exited with ${code}\n${err}`));
  17. });
  18. });
  19. }
  20. function escYaml(s) {
  21. return String(s).replaceAll('"', "\\\"");
  22. }
  23. function videoMd({ id, title, uploadDate, viewCount }) {
  24. const safeTitle = title?.trim() || id;
  25. // yt-dlp gives upload_date as YYYYMMDD.
  26. const pubDate = uploadDate && /^\d{8}$/.test(uploadDate)
  27. ? `${uploadDate.slice(0, 4)}-${uploadDate.slice(4, 6)}-${uploadDate.slice(6, 8)}`
  28. : new Date().toISOString().slice(0, 10);
  29. return `---\n` +
  30. `title: "${escYaml(safeTitle)}"\n` +
  31. `pubDate: ${pubDate}\n` +
  32. `views: ${Number.isFinite(Number(viewCount)) ? Number(viewCount) : 0}\n` +
  33. `description: "YouTube video"\n` +
  34. `---\n\n` +
  35. `<div style="position:relative; width:100%; aspect-ratio:16/9; background:#000;">\n` +
  36. ` <iframe\n` +
  37. ` title="${safeTitle.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}"\n` +
  38. ` src="https://www.youtube-nocookie.com/embed/${id}?rel=0"\n` +
  39. ` style="position:absolute; inset:0; width:100%; height:100%; border:0;"\n` +
  40. ` allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"\n` +
  41. ` allowfullscreen\n` +
  42. ` referrerpolicy="strict-origin-when-cross-origin"\n` +
  43. ` ></iframe>\n` +
  44. `</div>\n`;
  45. }
  46. async function main() {
  47. // Ensure output dir exists
  48. await fs.mkdir(OUT_DIR, { recursive: true });
  49. // Fetch list via yt-dlp.
  50. // Note: `--flat-playlist` does not include `upload_date` (it returns NA).
  51. // We fetch ids+titles flat, then fetch upload_date per video id.
  52. const { out } = await run("yt-dlp", [
  53. "--flat-playlist",
  54. "--print",
  55. "%(id)s\t%(title)s",
  56. CHANNEL_VIDEOS_URL,
  57. ]);
  58. const lines = out
  59. .split("\n")
  60. .map((l) => l.trim())
  61. .filter(Boolean);
  62. const videos = lines
  63. .map((l) => {
  64. const [id, ...rest] = l.split("\t");
  65. const title = rest.join("\t");
  66. return { id, title };
  67. })
  68. .filter((v) => v.id && v.id !== "NA");
  69. for (const v of videos) {
  70. try {
  71. const { out: metaOut } = await run("yt-dlp", [
  72. "--no-playlist",
  73. "--print",
  74. "%(upload_date)s\t%(view_count)s",
  75. `https://www.youtube.com/watch?v=${v.id}`,
  76. ]);
  77. const first = metaOut.trim().split("\n")[0] || "";
  78. const [uploadDate = "", viewCount = ""] = first.split("\t");
  79. v.uploadDate = uploadDate;
  80. v.viewCount = viewCount;
  81. } catch {
  82. v.uploadDate = "";
  83. v.viewCount = "";
  84. }
  85. }
  86. // Read existing generated files
  87. const existing = await fs.readdir(OUT_DIR);
  88. const existingGenerated = new Set(existing.filter((f) => f.startsWith("video-") && f.endsWith(".md")));
  89. // Write/update current
  90. for (const v of videos) {
  91. const fileName = `video-${v.id}.md`;
  92. existingGenerated.delete(fileName);
  93. await fs.writeFile(path.join(OUT_DIR, fileName), videoMd(v), "utf8");
  94. }
  95. // Remove stale generated files
  96. for (const stale of existingGenerated) {
  97. await fs.unlink(path.join(OUT_DIR, stale));
  98. }
  99. }
  100. main().catch((e) => {
  101. console.error("[sync-youtube-videos]", e?.message || e);
  102. process.exit(1);
  103. });