import { fileURLToPath } from "url"; const BASE = "https://v1.voratoon.com"; const DEFAULT_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7" }; async function fetchRSCData(url) { const res = await fetch(url, { headers: DEFAULT_HEADERS }); if (!res.ok) throw new Error(`HTTP ${res.status} untuk ${url}`); const html = await res.text(); const pushRe = /self\.__next_f\.push\(/g; let m; const starts = []; while ((m = pushRe.exec(html)) !== null) starts.push(m.index); let inner = ""; for (let j = 0; j < starts.length; j++) { const end = j + 1 < starts.length ? starts[j + 1] : html.length; const chunk = html.slice(starts[j], end); if (chunk.includes("initialData")) { const startIdx = chunk.indexOf('[1,"'); const endIdx = chunk.lastIndexOf('"]'); if (startIdx !== -1 && endIdx !== -1) { inner = chunk.slice(startIdx + 4, endIdx); } break; } } if (!inner) return []; let raw = inner .replace(/\\"/g, '"') .replace(/\\\\/g, '\\') .replace(/\\n/g, '\n') .replace(/\\r/g, ""); raw = raw.replace(/[\x00-\x1f]/g, ""); const markerObj = '"initialData":{'; const objIdx = raw.indexOf(markerObj); if (objIdx !== -1) { const objStart = objIdx + markerObj.length - 1; let depth = 0; for (let i = objStart; i < raw.length; i++) { if (raw[i] === "{") depth++; else if (raw[i] === "}") { depth--; if (depth === 0) { try { const obj = JSON.parse(raw.slice(objStart, i + 1)); const s = obj.series?.[0]; if (s) { const chapters = (obj.chapters ?? []).map((ch) => ({ chapterIndex: ch.data?.index || ch.chapterIndex, id: ch.id, data: ch.data })); return [{ id: s.id, data: s.data, chapters, updatedAt: s.updatedAt }]; } } catch {} break; } } } } const marker = '"initialData":['; const idx = raw.indexOf(marker); if (idx === -1) return []; const arrStart = idx + marker.length - 1; let depth2 = 0; for (let i = arrStart; i < raw.length; i++) { if (raw[i] === "[") depth2++; else if (raw[i] === "]") { depth2--; if (depth2 === 0) { try { return JSON.parse(raw.slice(arrStart, i + 1)); } catch {} } } } return []; } function timeAgo(iso) { if (!iso) return ""; const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m lalu`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}j lalu`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}h lalu`; return `${Math.floor(days / 30)}bln lalu`; } function fmtDate(iso) { if (!iso) return "?"; return new Date(iso).toLocaleDateString("id-ID", { day: "2-digit", month: "short", year: "numeric", timeZone: "Asia/Jakarta" }); } function seriesURL(slug) { return `${BASE}/series/${slug}`; } function chapterURL(slug, ch) { return `${BASE}/series/${encodeURIComponent(slug)}/chapter/${encodeURIComponent(ch)}`; } function extractSeries(item) { const d = item.data ?? {}; const chapters = item.chapters ?? []; const latest = chapters[0]; const genres = (d.genres ?? []).map((g) => g.data?.name || g.name).filter(Boolean).join(", "); return { id: item.id, title: d.title || "Untitled", nativeTitle: d.nativeTitle || "", slug: d.slug || "", coverImage: d.coverImage || "", backgroundImage: d.backgroundImage || "", synopsis: d.synopsis ? d.synopsis.replace(/\r\n/g, "\n").trim() : "", author: d.author || "Unknown", rating: d.rating || 0, status: d.status || "ongoing", format: d.format || "manga", totalChapters: d.totalChapters || `${chapters.length}`, genres: genres || "General", url: seriesURL(d.slug), latestChapter: latest ? { index: latest.chapterIndex || latest.data?.index, id: latest.id, url: chapterURL(d.slug, latest.chapterIndex || latest.data?.index) } : null, allChapters: chapters.map((ch) => ({ index: ch.chapterIndex || ch.data?.index, id: ch.id, title: `Chapter ${ch.chapterIndex || ch.data?.index}`, url: chapterURL(d.slug, ch.chapterIndex || ch.data?.index) })), updatedAt: item.updatedAt, timeAgoText: timeAgo(item.updatedAt), dateFormatted: fmtDate(item.updatedAt) }; } export async function getUpdates(opts = {}) { let pages; if (opts.all) { pages = Array.from({ length: 10 }, (_, i) => i + 1); } else if (opts.pages) { pages = opts.pages; } else { pages = [opts.page || 1]; } const allItems = []; for (const p of pages) { const url = p === 1 ? `${BASE}/updates` : `${BASE}/updates?page=${p}`; const items = await fetchRSCData(url); if (!items.length) break; allItems.push(...items); if (pages.length > 1) await new Promise((r) => setTimeout(r, 300)); } const seen = new Set(); const uniqueItems = allItems.filter((item) => { const slug = item.data?.slug; if (!slug || seen.has(slug)) return false; seen.add(slug); return true; }); return uniqueItems.map(extractSeries); } export async function getDetail(slug) { if (!slug) throw new Error("Slug manga wajib diisi."); const cleanSlug = slug.replace(/^https?:\/\/[^\/]+\/series\//i, "").replace(/^\//, ""); const url = `${BASE}/series/${cleanSlug}`; const items = await fetchRSCData(url); if (!items.length) throw new Error(`Manga dengan slug "${cleanSlug}" tidak ditemukan.`); return extractSeries(items[0]); } export async function getChapter(slug, chapterRef) { if (!slug || !chapterRef) throw new Error("Slug dan nomor chapter wajib diisi."); const cleanSlug = slug.replace(/^https?:\/\/[^\/]+\/series\//i, "").replace(/^\//, "").split("/")[0]; const cleanCh = String(chapterRef).replace(/^https?:\/\/.*\/chapter\//i, "").replace(/^\//, ""); const url = `${BASE}/series/${cleanSlug}/chapter/${cleanCh}`; const res = await fetch(url, { headers: DEFAULT_HEADERS }); if (!res.ok) throw new Error(`HTTP ${res.status} untuk ${url}`); const html = await res.text(); const pushRe = /self\.__next_f\.push\(/g; let m; const starts = []; while ((m = pushRe.exec(html)) !== null) starts.push(m.index); let inner = ""; for (let j = 0; j < starts.length; j++) { const end = j + 1 < starts.length ? starts[j + 1] : html.length; const chunk = html.slice(starts[j], end); if (chunk.includes("initialData")) { const startIdx = chunk.indexOf('[1,"'); const endIdx = chunk.lastIndexOf('"]'); if (startIdx !== -1 && endIdx !== -1) { inner = chunk.slice(startIdx + 4, endIdx); } break; } } let raw = inner .replace(/\\"/g, '"') .replace(/\\\\/g, '\\') .replace(/\\n/g, '\n') .replace(/\\r/g, ""); raw = raw.replace(/[\x00-\x1f]/g, ""); const markerObj = '"initialData":{'; const objIdx = raw.indexOf(markerObj); if (objIdx !== -1) { const objStart = objIdx + markerObj.length - 1; let depth = 0; for (let i = objStart; i < raw.length; i++) { if (raw[i] === "{") depth++; else if (raw[i] === "}") { depth--; if (depth === 0) { try { const obj = JSON.parse(raw.slice(objStart, i + 1)); const ch = obj.chapter; const s = obj.series?.[0]; const images = ch?.data?.images || []; return { seriesTitle: s?.data?.title || cleanSlug, slug: cleanSlug, chapterIndex: ch?.chapterIndex || cleanCh, title: ch?.data?.title || `Chapter ${cleanCh}`, images, url }; } catch {} break; } } } } throw new Error(`Gagal memuat chapter ${cleanCh} untuk ${cleanSlug}.`); } export const voratoon = { getUpdates, getDetail, getChapter }; export default voratoon; async function runCLI() { const args = process.argv.slice(2); const isJson = args.includes("--json"); const filteredArgs = args.filter((a) => a !== "--json"); if (filteredArgs.length === 0 || filteredArgs[0] === "--updates") { const pageArg = filteredArgs.find((a) => a.startsWith("--page=")); const page = pageArg ? parseInt(pageArg.split("=")[1], 10) : 1; const results = await getUpdates({ page }); if (isJson) { console.log(JSON.stringify(results, null, 2)); } else { console.log(`Voratoon Latest Updates (Halaman ${page}):\n`); results.forEach((item, i) => { const latest = item.latestChapter ? `Ch. ${item.latestChapter.index}` : "No Ch"; console.log(`${i + 1}. [${item.format.toUpperCase()}] ${item.title} (${latest}) - ${item.timeAgoText}`); console.log(` Slug: ${item.slug} | Rating: ${item.rating} | Genres: ${item.genres}`); console.log(` URL: ${item.url}\n`); }); } return; } if (filteredArgs[0] === "--chapter" || filteredArgs[0] === "-c") { const slug = filteredArgs[1]; const ch = filteredArgs[2]; if (!slug || !ch) { console.error("Penggunaan: node voratoon.js --chapter "); process.exit(1); } const result = await getChapter(slug, ch); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`\n=== ${result.seriesTitle} - ${result.title} ===`); console.log(`URL: ${result.url}`); console.log(`Total Gambar: ${result.images.length}\n`); result.images.forEach((img, idx) => { console.log(`[${idx + 1}] ${img}`); }); } return; } let slug = filteredArgs[0]; if (slug === "--detail" || slug === "-d") { slug = filteredArgs[1]; } if (!slug) { console.error("Penggunaan: node voratoon.js "); process.exit(1); } const result = await getDetail(slug); if (isJson) { console.log(JSON.stringify(result, null, 2)); } else { console.log(`\n=== ${result.title} (${result.nativeTitle || "-"}) ===`); console.log(`Author: ${result.author} | Status: ${result.status} | Rating: ${result.rating}`); console.log(`Format: ${result.format.toUpperCase()} | Total Chapter: ${result.totalChapters}`); console.log(`Genres: ${result.genres}`); console.log(`URL: ${result.url}\n`); console.log(`Sinopsis:\n${result.synopsis}\n`); console.log(`Daftar Chapter (${result.allChapters.length} tersedia):`); result.allChapters.slice(0, 10).forEach((ch) => { console.log(` - ${ch.title}: ${ch.url}`); }); if (result.allChapters.length > 10) { console.log(` ... dan ${result.allChapters.length - 10} chapter lainnya.`); } } } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { runCLI().catch((err) => { console.error("Error:", err.message); process.exit(1); }); }