Skip to content

Instantly share code, notes, and snippets.

@ardzz
Last active April 6, 2026 12:49
Show Gist options
  • Select an option

  • Save ardzz/47b5e71ed6d2cdd2829fe04a7b32bf84 to your computer and use it in GitHub Desktop.

Select an option

Save ardzz/47b5e71ed6d2cdd2829fe04a7b32bf84 to your computer and use it in GitHub Desktop.
YouTube Playlist Commenter — Automasi komentar kehadiran di playlist YouTube untuk keperluan akademik (Playwright Node.js)

YouTube Playlist Commenter

Automasi komentar kehadiran di seluruh video dalam playlist YouTube menggunakan Playwright (Node.js).

Dibuat untuk keperluan akademik — dosen meminta mahasiswa berkomentar sebagai bukti kehadiran di setiap video e-Learning.

Prasyarat

  • Node.js v18 atau lebih baru
  • Browser berbasis Chromium (Chrome, Edge, Brave, dll.)
  • Akun Google yang sudah terdaftar di YouTube

Instalasi

# 1. Clone gist atau salin file-file ke folder baru
mkdir youtube-commenter && cd youtube-commenter

# 2. Install dependency
npm install

# 3. Install browser Playwright (jika belum ada Chrome/Edge di sistem)
npx playwright install chromium

Catatan untuk Arch Linux / Archcraft: Jika npx playwright install chromium gagal, gunakan browser yang sudah terinstall di sistem. Ubah browser.channel di config.json menjadi "msedge" (Edge), "chrome" (Chrome), atau "chromium".

Konfigurasi

Edit file config.json sesuai kebutuhanmu:

{
  // URL playlist YouTube yang ingin dikomentari
  "playlist_url": "https://www.youtube.com/playlist?list=PLiHrX-EEKJ650iImEI-gEibMF0B2KWusz",

  // Isi komentar yang akan diposting di setiap video
  "comment": "Naufal Reky Ardhdna TI-4A, Masyallah tabakarakallah pak terimakasih materinya. Hadir",

  // Nama yang digunakan untuk mengecek apakah sudah pernah berkomentar (akan di-skip jika sudah ada)
  "commenter_name": "Naufal Reky",

  // Range video yang diproses (1-indexed, inklusif)
  "start_index": 1,
  "end_index": 36,

  // Folder tempat screenshot disimpan
  "screenshot_dir": "screenshots",

  // Pengaturan browser
  "browser": {
    "headless": false,          // false = browser terlihat (wajib untuk login manual)
    "channel": "msedge",        // "chrome" | "msedge" | "chromium" | ""
    "viewport": { "width": 1920, "height": 1080 },
    "user_data_dir": ""         // Kosong = sesi baru, isi path untuk persistent login
  },

  // Delay antar aksi (ms) — sesuaikan jika koneksi lambat
  "delays": {
    "page_load_ms": 3000,
    "scroll_ms": 1500,
    "after_click_placeholder_ms": 2000,
    "typing_delay_ms": 15,
    "after_submit_ms": 4000,
    "before_screenshot_ms": 500
  }
}

Persistent Login (Opsional)

Agar tidak perlu login ulang setiap kali menjalankan script, isi browser.user_data_dir dengan path folder profil browser:

"user_data_dir": "/tmp/yt-commenter-profile"

Script akan menyimpan cookie/session di folder tersebut.

Penggunaan

Menjalankan

npm start

Alur kerja:

  1. Browser terbuka dan membuka halaman playlist
  2. Jika belum login → script pause, minta kamu login manual di browser lalu tekan ENTER di terminal
  3. Script membaca seluruh video dari playlist
  4. Untuk setiap video:
    • Buka video
    • Scroll ke kolom komentar
    • Cek apakah sudah ada komentar dari commenter_name
    • Jika belum → posting komentar
    • Jika sudah → skip
    • Screenshot halaman (judul video + komentar terlihat)
  5. Tampilkan ringkasan di akhir

Dry Run (Simulasi)

Cek tanpa benar-benar memposting komentar:

npm run dry-run

Contoh Output

[17:10:05] Browser terbuka.
[17:10:08] Membuka halaman playlist...
[17:10:12] Ditemukan 36 video di playlist.
[17:10:12] Akan memproses 36 video (index 1-36).
[17:10:12] Sudah login.
[17:10:15] [1/36] Membuka: Week 1 - Introduction to IoT - Part 1 (dari 5)
[17:10:25]   -> Komentar berhasil diposting.
[17:10:26]   -> Screenshot: screenshots/video_01_Week 1 - Introduction to IoT - Part 1 dari 5.png
...
[17:21:30] ════════════════════════════════════════════════════
[17:21:30]                     RINGKASAN
[17:21:30] ════════════════════════════════════════════════════
[17:21:30]   Total video   : 36
[17:21:30]   Dikomentari   : 36
[17:21:30]   Sudah ada     : 0 (di-skip)
[17:21:30]   Gagal         : 0
[17:21:30]   Screenshot di : ./screenshots/
[17:21:30] ════════════════════════════════════════════════════

Struktur File

youtube-commenter/
├── README.md                 ← Dokumentasi
├── config.json               ← Konfigurasi (edit ini)
├── package.json              ← Dependencies
├── youtube-commenter.mjs     ← Script utama
└── screenshots/              ← Hasil screenshot (auto-generated)
    ├── video_01_Week 1 - Introduction to IoT - Part 1 dari 5.png
    ├── video_02_Week 1 - Introduction to IoT - Part 2 dari 5.png
    └── ...

Troubleshooting

Masalah Solusi
Browser not found Pastikan Chrome/Edge terinstall, atau ubah browser.channel di config
Comment placeholder not found Koneksi lambat — naikkan delays.page_load_ms dan delays.scroll_ms
Comment box not found YouTube mungkin berubah layout — coba jalankan ulang
Login tidak tersimpan Isi browser.user_data_dir di config untuk persistent session
Hanya ingin video tertentu Ubah start_index dan end_index di config

Disclaimer

Script ini dibuat khusus untuk keperluan bersenang-senang, ngapain sih semester 8 masih ada matkul tugasnya juga gaje komen yt, repetitive materi diulang terus rugi bayar ukt bos

{
"playlist_url": "https://www.youtube.com/playlist?list=PLiHrX-EEKJ650iImEI-gEibMF0B2KWusz",
"comment": "Naufal Reky Ardhdna TI-4A, Masyallah tabakarakallah pak terimakasih materinya. Hadir",
"commenter_name": "Naufal Reky",
"start_index": 1,
"end_index": 36,
"screenshot_dir": "screenshots",
"browser": {
"headless": false,
"channel": "msedge",
"viewport": { "width": 1920, "height": 1080 },
"user_data_dir": ""
},
"delays": {
"page_load_ms": 3000,
"scroll_ms": 1500,
"after_click_placeholder_ms": 2000,
"typing_delay_ms": 15,
"after_submit_ms": 4000,
"before_screenshot_ms": 500
}
}
{
"name": "youtube-playlist-commenter",
"version": "1.0.0",
"description": "Automasi komentar kehadiran di playlist YouTube untuk keperluan akademik",
"type": "module",
"scripts": {
"start": "node youtube-commenter.mjs",
"dry-run": "node youtube-commenter.mjs --dry-run"
},
"dependencies": {
"playwright": "^1.52.0"
}
}
/**
* YouTube Playlist Commenter
*
* Automasi komentar kehadiran di seluruh video dalam sebuah playlist YouTube.
* Dibuat untuk keperluan akademik — dosen meminta mahasiswa berkomentar
* sebagai bukti kehadiran di setiap video e-Learning.
*
* Cara pakai:
* 1. npm install
* 2. Sesuaikan config.json (nama, komentar, playlist URL, dll.)
* 3. npm start — jalankan (akan buka browser, perlu login manual sekali)
* 4. npm run dry-run — simulasi tanpa posting komentar
*
* Catatan:
* - Browser akan terbuka dalam mode NON-headless supaya kamu bisa login Google
* secara manual di awal. Setelah login, script melanjutkan otomatis.
* - Jika sudah pernah berkomentar di suatu video, script akan SKIP dan langsung screenshot.
* - Screenshot disimpan di folder ./screenshots/ dengan nama file berisi nomor & judul video.
*/
import { chromium } from "playwright";
import { readFileSync, mkdirSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline";
// ── Helpers ────────────────────────────────────────────────────────────────────
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function loadConfig() {
const raw = readFileSync(
join(resolve(import.meta.dirname ?? "."), "config.json"),
"utf-8",
);
return JSON.parse(raw);
}
function sanitizeFilename(str) {
return str
.replace(/[^a-zA-Z0-9_\- ]/g, "")
.trim()
.substring(0, 80);
}
function log(msg) {
const ts = new Date().toLocaleTimeString("id-ID", { hour12: false });
console.log(`[${ts}] ${msg}`);
}
function prompt(question) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) =>
rl.question(question, (ans) => {
rl.close();
resolve(ans);
}),
);
}
// ── Scrape playlist ────────────────────────────────────────────────────────────
async function scrapePlaylist(page, playlistUrl) {
log("Membuka halaman playlist...");
await page.goto(playlistUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
await sleep(3000);
const videos = await page.evaluate(() => {
const items = document.querySelectorAll("ytd-playlist-video-renderer");
const result = [];
items.forEach((item, i) => {
const titleEl = item.querySelector("h3 a#video-title");
if (!titleEl) return;
const href = titleEl.getAttribute("href") || "";
const match = href.match(/[?&]v=([^&]+)/);
if (!match) return;
// Use the title attribute for clean text (no duration/status junk)
const title =
titleEl.getAttribute("title") ||
titleEl.textContent.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
result.push({
index: i + 1,
videoId: match[1],
title,
url: `https://www.youtube.com${href}`,
});
});
return result;
});
log(`Ditemukan ${videos.length} video di playlist.`);
return videos;
}
// ── Comment on a single video ──────────────────────────────────────────────────
async function commentOnVideo(page, video, config, dryRun) {
const { comment, commenter_name: myName, delays } = config;
log(`[${video.index}/${config.end_index}] Membuka: ${video.title}`);
await page.goto(video.url, { waitUntil: "domcontentloaded", timeout: 30_000 }).catch(() => {});
await sleep(delays.page_load_ms);
// Scroll to load comments
for (let i = 0; i < 4; i++) {
await page.evaluate(() => window.scrollBy(0, 600));
await sleep(delays.scroll_ms);
}
// Check if already commented
const alreadyCommented = await page.evaluate((name) => {
const comments = document.querySelectorAll(
"ytd-comment-view-model, ytd-comment-renderer",
);
for (const c of comments) {
if (c.textContent.includes(name)) return true;
}
return false;
}, myName);
if (alreadyCommented) {
log(` -> Sudah ada komentar dari "${myName}", SKIP.`);
} else if (dryRun) {
log(` -> [DRY-RUN] Akan mengomentari: "${comment}"`);
} else {
// Find comment placeholder
let placeholder = null;
for (let i = 0; i < 15; i++) {
placeholder = await page.$("#simplebox-placeholder");
if (placeholder) break;
await page.evaluate(() => window.scrollBy(0, 300));
await sleep(1000);
}
if (!placeholder) {
log(" -> GAGAL: Comment placeholder tidak ditemukan.");
return { success: false, reason: "no placeholder" };
}
// Activate comment box
await placeholder.click();
await sleep(delays.after_click_placeholder_ms);
const box = await page.$("#contenteditable-root");
if (!box) {
log(" -> GAGAL: Comment box tidak muncul.");
return { success: false, reason: "no comment box" };
}
// Type comment
await box.focus();
await box.click();
await page.keyboard.type(comment, { delay: delays.typing_delay_ms });
await sleep(1000);
// Submit
const submitBtn = await page.$("#submit-button yt-button-shape button");
if (!submitBtn) {
log(" -> GAGAL: Tombol submit tidak ditemukan.");
return { success: false, reason: "no submit button" };
}
// Wait until enabled
for (let i = 0; i < 10; i++) {
const disabled = await submitBtn.evaluate(
(el) => el.disabled || el.getAttribute("aria-disabled") === "true",
);
if (!disabled) break;
await sleep(500);
}
await submitBtn.click();
await sleep(delays.after_submit_ms);
log(" -> Komentar berhasil diposting.");
}
// ── Screenshot ───────────────────────────────────────────────────────────────
// Scroll so the video title is visible at the top of the viewport
await page.evaluate(() => {
const el = document.querySelector("h1.ytd-watch-metadata");
if (el) {
el.scrollIntoView({ block: "start" });
window.scrollBy(0, -10);
}
});
await sleep(delays.before_screenshot_ms);
const safeName = sanitizeFilename(video.title);
const filename = `video_${String(video.index).padStart(2, "0")}_${safeName}.png`;
const filepath = join(config.screenshot_dir, filename);
await page.screenshot({ path: filepath });
log(` -> Screenshot: ${filepath}`);
return { success: true, alreadyCommented, filename };
}
// ── Main ───────────────────────────────────────────────────────────────────────
async function main() {
const dryRun = process.argv.includes("--dry-run");
if (dryRun) log("=== MODE DRY-RUN: tidak akan posting komentar ===");
const config = loadConfig();
// Ensure screenshot dir exists
if (!existsSync(config.screenshot_dir)) {
mkdirSync(config.screenshot_dir, { recursive: true });
}
// Launch browser
const launchOptions = {
headless: config.browser.headless,
channel: config.browser.channel || undefined,
};
// Use persistent context if user_data_dir is set (keeps login session)
let browser, context, page;
if (config.browser.user_data_dir) {
context = await chromium.launchPersistentContext(
config.browser.user_data_dir,
{
...launchOptions,
viewport: config.browser.viewport,
},
);
page = context.pages()[0] || (await context.newPage());
browser = null; // persistent context doesn't have a separate browser object
} else {
browser = await chromium.launch(launchOptions);
context = await browser.newContext({ viewport: config.browser.viewport });
page = await context.newPage();
}
log("Browser terbuka.");
// ── Step 1: Scrape playlist ────────────────────────────────────────────────
const allVideos = await scrapePlaylist(page, config.playlist_url);
if (allVideos.length === 0) {
log("Tidak ada video ditemukan. Cek playlist URL di config.json.");
await cleanup(browser, context);
return;
}
// Filter by start/end index
const videos = allVideos.filter(
(v) => v.index >= config.start_index && v.index <= config.end_index,
);
log(`Akan memproses ${videos.length} video (index ${config.start_index}-${config.end_index}).`);
// ── Step 2: Check login ────────────────────────────────────────────────────
const isLoggedIn = await page.evaluate(() => {
return !document.querySelector('a[href*="ServiceLogin"]');
});
if (!isLoggedIn) {
log("");
log("====================================================");
log(" BELUM LOGIN! Silakan login di browser yang terbuka.");
log("====================================================");
log("");
await prompt("Tekan ENTER setelah selesai login... ");
// Re-check
await page.goto(config.playlist_url, { waitUntil: "domcontentloaded" });
await sleep(2000);
const stillNotLoggedIn = await page.evaluate(() => {
return !!document.querySelector('a[href*="ServiceLogin"]');
});
if (stillNotLoggedIn) {
log("Masih belum login. Script berhenti.");
await cleanup(browser, context);
return;
}
log("Login berhasil!");
} else {
log("Sudah login.");
}
// ── Step 3: Process each video ─────────────────────────────────────────────
const results = [];
for (const video of videos) {
const result = await commentOnVideo(page, video, config, dryRun);
results.push({ ...video, ...result });
}
// ── Step 4: Summary ────────────────────────────────────────────────────────
log("");
log("════════════════════════════════════════════════════");
log(" RINGKASAN");
log("════════════════════════════════════════════════════");
const commented = results.filter((r) => r.success && !r.alreadyCommented);
const skipped = results.filter((r) => r.success && r.alreadyCommented);
const failed = results.filter((r) => !r.success);
log(` Total video : ${results.length}`);
log(` Dikomentari : ${commented.length}`);
log(` Sudah ada : ${skipped.length} (di-skip)`);
log(` Gagal : ${failed.length}`);
log(` Screenshot di : ./${config.screenshot_dir}/`);
if (failed.length > 0) {
log("");
log("Video yang gagal:");
for (const f of failed) {
log(` - [${f.index}] ${f.title} (${f.reason})`);
}
}
log("════════════════════════════════════════════════════");
await cleanup(browser, context);
}
async function cleanup(browser, context) {
if (browser) await browser.close();
else if (context) await context.close();
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment