Tujuan Utama: Sang Asisten Digital
Pada dasarnya, skrip ini adalah asisten digital yang mengambil alih semua tugas membosankan. Tujuannya adalah membaca semua artikel .html, mengekstrak data penting, lalu menghasilkan tiga hal utama:
- Sebuah "database" pusat dalam format JSON (
artikel.json). - Sebuah
sitemap.xmlyang selalu up-to-date untuk SEO. - Halaman-halaman kategori artikel yang dibuat secara otomatis.
Alur Kerja: Bagaimana Si Cerdas Bekerja
Skrip ini tidak hanya bekerja, tapi bekerja dengan alur yang efisien dan tangguh. Berikut adalah visualisasi prosesnya:

Kode Lengkap: generator.js
Berikut adalah kode lengkap dari skrip generator konten cerdas yang kita bedah di atas.
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { titleToCategory } from './titleToCategory.js';
// ==================================================================
// KONFIGURASI TERPUSAT
// ===================================================================
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG = {
rootDir: path.join(__dirname, '..'),
artikelDir: path.join(__dirname, '..', 'artikel'),
masterJson: path.join(__dirname, '..', 'artikel', 'artikel.json'),
jsonOut: path.join(__dirname, '..', 'artikel.json'),
xmlOut: path.join(__dirname, '..', 'sitemap.xml'),
baseUrl: 'https://dalam.web.id',
defaultThumbnail: 'https://dalam.web.id/thumbnail.webp',
xmlPriority: '0.6',
xmlChangeFreq: 'monthly',
};
// ===================================================================
// FUNGSI-FUNGSI BANTUAN (HELPER FUNCTIONS)
// ===================================================================
function formatISO8601(date) {
const d = new Date(date);
if (isNaN(d)) {
console.warn(`ā ļø Tanggal tidak valid, fallback ke sekarang.`);
return new Date().toISOString();
}
const tzOffset = -d.getTimezoneOffset();
const diff = tzOffset >= 0 ? '+' : '-';
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0');
const hours = pad(tzOffset / 60);
const minutes = pad(tzOffset % 60);
return d.toISOString().replace('Z', `${diff}${hours}:${minutes}`);
}
function extractPubDate(content) {
const match = content.match(
/<meta\s+property=["']article:published_time["'][^>]+content=["']([^"']+)["']/i,
);
return match ? match[1].trim() : null;
}
function extractTitle(content) {
const match = content.match(/<title>([\s\S]*?)<\/title>/i);
return match ? match[1].trim() : 'Tanpa Judul';
}
function extractDescription(content) {
const match = content.match(
/<meta\s+name=["']description["'][^>]+content=["']([^"']+)["']/i,
);
return match ? match[1].trim() : '';
}
function fixTitleOneLine(content) {
return content.replace(
/<title>([\s\S]*?)<\/title>/gi,
(m, p1) => `<title>${p1.trim()}</title>`,
);
}
function extractImage(content) {
const ogMatch = content.match(
/<meta[^>]+property=["']og:image["'][^>]+content=["'](.*?)["']/i,
);
if (ogMatch && ogMatch[1]) {
const src = ogMatch[1].trim();
const validExt = /\.(jpe?g|png|gif|webp|avif|svg)$/i;
if (validExt.test(src.split('?')[0])) return src;
}
const imgMatch = content.match(/<img[^>]+src=["'](.*?)["']/i);
if (imgMatch && imgMatch[1]) {
const src = imgMatch[1].trim();
const validExt = /\.(jpe?g|png|gif|webp|avif|svg)$/i;
if (validExt.test(src.split('?')[0])) return src;
}
return CONFIG.defaultThumbnail;
}
function formatJsonOutput(obj) {
return JSON.stringify(obj, null, 2)
.replace(/\[\s*\[/g, '[\n [')
.replace(/\]\s*\]/g, ']\n ]')
.replace(/(\],)\s*\[/g, '$1\n [');
}
async function generateCategoryPages(groupedData) {
console.log('š Memulai pembuatan halaman kategori...');
const kategoriDir = path.join(CONFIG.artikelDir, '-');
const templatePath = path.join(kategoriDir, 'template-kategori.html');
try {
await fs.access(templatePath);
const templateContent = await fs.readFile(templatePath, 'utf8');
for (const categoryName in groupedData) {
// Hilangkan emoji & ubah ke slug
const noEmoji = categoryName.replace(/^[^\w\s]*/, '').trim();
const slug = noEmoji
.toLowerCase()
.replace(/ & /g, '-and-')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
// RSS URL untuk kategori
const rssUrl = `${CONFIG.baseUrl}/feed-${slug}.xml`;
// URL halaman kategori
const fileName = `${slug}.html`;
const canonicalUrl = `${CONFIG.baseUrl}/artikel/-/${fileName}`;
// Ambil emoji pertama (atau fallback)
const icon = categoryName.match(/(\p{Emoji})/u)?.[0] || 'š';
// Ganti placeholder di template
let pageContent = templateContent
.replace(/%%TITLE%%/g, noEmoji)
.replace(/%%DESCRIPTION%%/g, `topik ${noEmoji}`)
.replace(/%%CANONICAL_URL%%/g, canonicalUrl)
.replace(/%%CATEGORY_NAME%%/g, categoryName)
.replace(/%%ICON%%/g, icon)
.replace(/%%RSS_URL%%/g, rssUrl);
// Tulis file HTML hasil render
await fs.writeFile(path.join(kategoriDir, fileName), pageContent, 'utf8');
console.log(`ā
Halaman kategori dibuat: ${fileName}`);
}
console.log('š Semua halaman kategori berhasil dibuat.');
} catch (error) {
console.error(
"ā Gagal membuat halaman kategori. Pastikan 'template-kategori.html' ada di dalam folder 'artikel/-'.",
error.message,
);
}
}
// ===================================================================
// FUNGSI UTAMA (MAIN GENERATOR)
// ===================================================================
const generate = async () => {
console.log('š Memulai proses generator...');
try {
await fs.access(CONFIG.artikelDir);
} catch {
console.error("ā Folder 'artikel' tidak ditemukan. Proses dibatalkan.");
return;
}
const filesOnDisk = (await fs.readdir(CONFIG.artikelDir)).filter((f) =>
f.endsWith('.html'),
);
const existingFilesOnDisk = new Set(filesOnDisk);
let grouped = {};
try {
const masterContent = await fs.readFile(CONFIG.masterJson, 'utf8');
grouped = JSON.parse(masterContent);
console.log('š Master JSON berhasil dimuat.');
} catch {
console.warn(
'ā ļø Master JSON (artikel/artikel.json) tidak ditemukan, memulai dari awal.',
);
}
const cleanedGrouped = {};
let deletedCount = 0;
for (const category in grouped) {
const survivingArticles = grouped[category].filter((item) => {
if (!existingFilesOnDisk.has(item[1])) {
console.log(
`šļø File terhapus terdeteksi, menghapus dari data: ${item[1]}`,
);
deletedCount++;
return false;
}
return true;
});
if (survivingArticles.length > 0) {
cleanedGrouped[category] = survivingArticles;
}
}
grouped = cleanedGrouped;
const existingFiles = new Set(
Object.values(grouped)
.flat()
.map((item) => item[1]),
);
let newArticlesCount = 0;
for (const file of filesOnDisk) {
try {
if (existingFiles.has(file)) continue;
const fullPath = path.join(CONFIG.artikelDir, file);
let content = await fs.readFile(fullPath, 'utf8');
let needsSave = false;
const fixedTitleContent = fixTitleOneLine(content);
if (fixedTitleContent !== content) {
content = fixedTitleContent;
needsSave = true;
console.log(`š§ Merapikan <title> di ${file}`);
}
const title = extractTitle(content);
const category = titleToCategory(title);
const image = extractImage(content);
const description = extractDescription(content);
let pubDate = extractPubDate(content);
if (!pubDate) {
const stats = await fs.stat(fullPath);
pubDate = stats.mtime;
const newMetaTag = ` <meta property="article:published_time" content="${formatISO8601(pubDate)}">`;
if (content.includes('</head>')) {
content = content.replace('</head>', `${newMetaTag}\n</head>`);
needsSave = true;
console.log(`ā Menambahkan meta tanggal ke '${file}'`);
}
}
if (needsSave) {
await fs.writeFile(fullPath, content, 'utf8');
}
const lastmod = formatISO8601(pubDate);
if (!grouped[category]) grouped[category] = [];
grouped[category].push([title, file, image, lastmod, description]);
newArticlesCount++;
console.log(`ā Memproses artikel baru: ${file}`);
} catch (error) {
console.error(`ā Gagal memproses file ${file}:`, error.message);
}
}
// Cek apakah file output (artikel.json di root) sudah ada
let isJsonOutMissing = false;
try {
await fs.access(CONFIG.jsonOut);
} catch {
isJsonOutMissing = true;
console.log('š” File output artikel.json tidak ditemukan, akan dibuat ulang.');
}
// Tambahkan 'isJsonOutMissing' sebagai kondisi untuk menulis file
if (newArticlesCount > 0 || deletedCount > 0 || isJsonOutMissing) {
for (const category in grouped) {
grouped[category].sort((a, b) => new Date(b[3]) - new Date(a[3]));
}
const xmlUrls = [];
Object.values(grouped)
.flat()
.forEach((item) => {
const [, file, image, lastmod] = item;
xmlUrls.push(
` <url>\n <loc>${CONFIG.baseUrl}/artikel/${file}</loc>\n <lastmod>${lastmod}</lastmod>\n <priority>${CONFIG.xmlPriority}</priority>\n <changefreq>${CONFIG.xmlChangeFreq}</changefreq>\n <image:image>\n <image:loc>${image}</image:loc>\n </image:image>\n </url>`,
);
});
const jsonString = formatJsonOutput(grouped);
await fs.writeFile(CONFIG.jsonOut, jsonString, 'utf8');
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd http://www.google.com/schemas/sitemap/image/1.1 http://www.google.com/schemas/sitemap/1.1/sitemap-image.xsd" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${xmlUrls.join('\n')}\n</urlset>`;
await fs.writeFile(CONFIG.xmlOut, xmlContent, 'utf8');
await generateCategoryPages(grouped);
console.log(
`\nā
Ringkasan: ${newArticlesCount} artikel baru ditambahkan, ${deletedCount} artikel lama dihapus.`,
);
if (isJsonOutMissing && newArticlesCount === 0 && deletedCount === 0) {
console.log('ā
File artikel.json di root dibuat ulang karena tidak ada.');
}
console.log(
'ā
artikel.json, sitemap.xml, dan halaman kategori berhasil diperbarui.',
);
} else {
console.log('\nā
Tidak ada perubahan. File tidak diubah.');
}
};
// Menjalankan fungsi utama
generate();
Langkah 1: Inisialisasi & Memuat Ingatan
Skrip mulai dengan memuat artikel/artikel.json. File ini bertindak sebagai "ingatan" atau kondisi terakhir. Jika tidak ada, ia akan memulai dari nol.
Langkah 2: Pembersihan & Sinkronisasi
Ini bagian cerdasnya. Skrip membandingkan "ingatannya" dengan file yang benar-benar ada. Jika ada artikel yang sudah dihapus, datanya akan dibersihkan dari JSON.
Langkah 3: Memproses Artikel Baru
Skrip mencari file .html baru yang belum ada di "ingatannya", lalu mengekstrak semua metadata penting: judul, deskripsi, gambar, dan tanggal.
Langkah 4: Perbaikan Otomatis (Self-Healing)
Jika sebuah artikel tidak punya meta tag tanggal terbit, skrip akan mengambil tanggal modifikasi file, lalu menyuntikkan tag meta yang benar ke dalam file HTML tersebut. Ajaib!
Langkah 5: Menulis Output (Hanya Jika Perlu)
Ini bagian efisiennya. Skrip hanya akan menulis ulang artikel.json, sitemap.xml, dan halaman kategori jika ada perubahan. Jika tidak, ia akan berhenti dan menghemat waktu.
Fitur-Fitur Cerdas Lainnya
Konfigurasi Terpusat
Semua path dan pengaturan penting ada di satu tempat (objek CONFIG), membuatnya sangat mudah dipelihara.
Fungsi yang Jelas
Kode dipecah menjadi fungsi-fungsi kecil dengan satu tugas, seperti extractTitle, membuatnya mudah dibaca dan di-debug.
Fallback Cerdas
Jika tidak menemukan gambar di artikel, skrip akan otomatis menggunakan gambar thumbnail default yang sudah ditentukan.