Back to Home
Gistify Snippet
Public
Voratoon Manga & Manhwa Scraper (v1.voratoon.com)
Node.js scraper for Voratoon manga and manhwa platform. Extracts latest updates, detailed series metadata with chapter lists, and full chapter reader images via Next.js RSC payload parsing.
#scraper#manga#manhwa#voratoon#nodejs#crawler
5
Rawvoratoon-scraper.jsjavascript
352 lines| 1 | import { fileURLToPath } from "url"; |
| 2 | |
| 3 | const BASE = "https://v1.voratoon.com"; |
| 4 | const DEFAULT_HEADERS = { |
| 5 | "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", |
| 6 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", |
| 7 | "Accept-Language": "id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7" |
| 8 | }; |
| 9 | |
| 10 | async function fetchRSCData(url) { |
| 11 | const res = await fetch(url, { headers: DEFAULT_HEADERS }); |
| 12 | if (!res.ok) throw new Error(`HTTP ${res.status} untuk ${url}`); |
| 13 | const html = await res.text(); |
| 14 | |
| 15 | const pushRe = /self\.__next_f\.push\(/g; |
| 16 | let m; |
| 17 | const starts = []; |
| 18 | while ((m = pushRe.exec(html)) !== null) starts.push(m.index); |
| 19 | |
| 20 | let inner = ""; |
| 21 | for (let j = 0; j < starts.length; j++) { |
| 22 | const end = j + 1 < starts.length ? starts[j + 1] : html.length; |
| 23 | const chunk = html.slice(starts[j], end); |
| 24 | if (chunk.includes("initialData")) { |
| 25 | const startIdx = chunk.indexOf('[1,"'); |
| 26 | const endIdx = chunk.lastIndexOf('"]'); |
| 27 | if (startIdx !== -1 && endIdx !== -1) { |
| 28 | inner = chunk.slice(startIdx + 4, endIdx); |
| 29 | } |
| 30 | break; |
| 31 | } |
| 32 | } |
| 33 | if (!inner) return []; |
| 34 | |
| 35 | let raw = inner |
| 36 | .replace(/\\"/g, '"') |
| 37 | .replace(/\\\\/g, '\\') |
| 38 | .replace(/\\n/g, '\n') |
| 39 | .replace(/\\r/g, ""); |
| 40 | raw = raw.replace(/[\x00-\x1f]/g, ""); |
| 41 | |
| 42 | const markerObj = '"initialData":{'; |
| 43 | const objIdx = raw.indexOf(markerObj); |
| 44 | if (objIdx !== -1) { |
| 45 | const objStart = objIdx + markerObj.length - 1; |
| 46 | let depth = 0; |
| 47 | for (let i = objStart; i < raw.length; i++) { |
| 48 | if (raw[i] === "{") depth++; |
| 49 | else if (raw[i] === "}") { |
| 50 | depth--; |
| 51 | if (depth === 0) { |
| 52 | try { |
| 53 | const obj = JSON.parse(raw.slice(objStart, i + 1)); |
| 54 | const s = obj.series?.[0]; |
| 55 | if (s) { |
| 56 | const chapters = (obj.chapters ?? []).map((ch) => ({ |
| 57 | chapterIndex: ch.data?.index || ch.chapterIndex, |
| 58 | id: ch.id, |
| 59 | data: ch.data |
| 60 | })); |
| 61 | return [{ id: s.id, data: s.data, chapters, updatedAt: s.updatedAt }]; |
| 62 | } |
| 63 | } catch {} |
| 64 | break; |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | const marker = '"initialData":['; |
| 71 | const idx = raw.indexOf(marker); |
| 72 | if (idx === -1) return []; |
| 73 | const arrStart = idx + marker.length - 1; |
| 74 | let depth2 = 0; |
| 75 | for (let i = arrStart; i < raw.length; i++) { |
| 76 | if (raw[i] === "[") depth2++; |
| 77 | else if (raw[i] === "]") { |
| 78 | depth2--; |
| 79 | if (depth2 === 0) { |
| 80 | try { |
| 81 | return JSON.parse(raw.slice(arrStart, i + 1)); |
| 82 | } catch {} |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | return []; |
| 87 | } |
| 88 | |
| 89 | function timeAgo(iso) { |
| 90 | if (!iso) return ""; |
| 91 | const diff = Date.now() - new Date(iso).getTime(); |
| 92 | const mins = Math.floor(diff / 60000); |
| 93 | if (mins < 60) return `${mins}m lalu`; |
| 94 | const hrs = Math.floor(mins / 60); |
| 95 | if (hrs < 24) return `${hrs}j lalu`; |
| 96 | const days = Math.floor(hrs / 24); |
| 97 | if (days < 30) return `${days}h lalu`; |
| 98 | return `${Math.floor(days / 30)}bln lalu`; |
| 99 | } |
| 100 | |
| 101 | function fmtDate(iso) { |
| 102 | if (!iso) return "?"; |
| 103 | return new Date(iso).toLocaleDateString("id-ID", { |
| 104 | day: "2-digit", |
| 105 | month: "short", |
| 106 | year: "numeric", |
| 107 | timeZone: "Asia/Jakarta" |
| 108 | }); |
| 109 | } |
| 110 | |
| 111 | function seriesURL(slug) { |
| 112 | return `${BASE}/series/${slug}`; |
| 113 | } |
| 114 | |
| 115 | function chapterURL(slug, ch) { |
| 116 | return `${BASE}/series/${encodeURIComponent(slug)}/chapter/${encodeURIComponent(ch)}`; |
| 117 | } |
| 118 | |
| 119 | function extractSeries(item) { |
| 120 | const d = item.data ?? {}; |
| 121 | const chapters = item.chapters ?? []; |
| 122 | const latest = chapters[0]; |
| 123 | const genres = (d.genres ?? []).map((g) => g.data?.name || g.name).filter(Boolean).join(", "); |
| 124 | |
| 125 | return { |
| 126 | id: item.id, |
| 127 | title: d.title || "Untitled", |
| 128 | nativeTitle: d.nativeTitle || "", |
| 129 | slug: d.slug || "", |
| 130 | coverImage: d.coverImage || "", |
| 131 | backgroundImage: d.backgroundImage || "", |
| 132 | synopsis: d.synopsis ? d.synopsis.replace(/\r\n/g, "\n").trim() : "", |
| 133 | author: d.author || "Unknown", |
| 134 | rating: d.rating || 0, |
| 135 | status: d.status || "ongoing", |
| 136 | format: d.format || "manga", |
| 137 | totalChapters: d.totalChapters || `${chapters.length}`, |
| 138 | genres: genres || "General", |
| 139 | url: seriesURL(d.slug), |
| 140 | latestChapter: latest ? { |
| 141 | index: latest.chapterIndex || latest.data?.index, |
| 142 | id: latest.id, |
| 143 | url: chapterURL(d.slug, latest.chapterIndex || latest.data?.index) |
| 144 | } : null, |
| 145 | allChapters: chapters.map((ch) => ({ |
| 146 | index: ch.chapterIndex || ch.data?.index, |
| 147 | id: ch.id, |
| 148 | title: `Chapter ${ch.chapterIndex || ch.data?.index}`, |
| 149 | url: chapterURL(d.slug, ch.chapterIndex || ch.data?.index) |
| 150 | })), |
| 151 | updatedAt: item.updatedAt, |
| 152 | timeAgoText: timeAgo(item.updatedAt), |
| 153 | dateFormatted: fmtDate(item.updatedAt) |
| 154 | }; |
| 155 | } |
| 156 | |
| 157 | export async function getUpdates(opts = {}) { |
| 158 | let pages; |
| 159 | if (opts.all) { |
| 160 | pages = Array.from({ length: 10 }, (_, i) => i + 1); |
| 161 | } else if (opts.pages) { |
| 162 | pages = opts.pages; |
| 163 | } else { |
| 164 | pages = [opts.page || 1]; |
| 165 | } |
| 166 | |
| 167 | const allItems = []; |
| 168 | for (const p of pages) { |
| 169 | const url = p === 1 ? `${BASE}/updates` : `${BASE}/updates?page=${p}`; |
| 170 | const items = await fetchRSCData(url); |
| 171 | if (!items.length) break; |
| 172 | allItems.push(...items); |
| 173 | if (pages.length > 1) await new Promise((r) => setTimeout(r, 300)); |
| 174 | } |
| 175 | |
| 176 | const seen = new Set(); |
| 177 | const uniqueItems = allItems.filter((item) => { |
| 178 | const slug = item.data?.slug; |
| 179 | if (!slug || seen.has(slug)) return false; |
| 180 | seen.add(slug); |
| 181 | return true; |
| 182 | }); |
| 183 | |
| 184 | return uniqueItems.map(extractSeries); |
| 185 | } |
| 186 | |
| 187 | export async function getDetail(slug) { |
| 188 | if (!slug) throw new Error("Slug manga wajib diisi."); |
| 189 | const cleanSlug = slug.replace(/^https?:\/\/[^\/]+\/series\//i, "").replace(/^\//, ""); |
| 190 | const url = `${BASE}/series/${cleanSlug}`; |
| 191 | const items = await fetchRSCData(url); |
| 192 | if (!items.length) throw new Error(`Manga dengan slug "${cleanSlug}" tidak ditemukan.`); |
| 193 | return extractSeries(items[0]); |
| 194 | } |
| 195 | |
| 196 | export async function getChapter(slug, chapterRef) { |
| 197 | if (!slug || !chapterRef) throw new Error("Slug dan nomor chapter wajib diisi."); |
| 198 | const cleanSlug = slug.replace(/^https?:\/\/[^\/]+\/series\//i, "").replace(/^\//, "").split("/")[0]; |
| 199 | const cleanCh = String(chapterRef).replace(/^https?:\/\/.*\/chapter\//i, "").replace(/^\//, ""); |
| 200 | const url = `${BASE}/series/${cleanSlug}/chapter/${cleanCh}`; |
| 201 | |
| 202 | const res = await fetch(url, { headers: DEFAULT_HEADERS }); |
| 203 | if (!res.ok) throw new Error(`HTTP ${res.status} untuk ${url}`); |
| 204 | const html = await res.text(); |
| 205 | |
| 206 | const pushRe = /self\.__next_f\.push\(/g; |
| 207 | let m; |
| 208 | const starts = []; |
| 209 | while ((m = pushRe.exec(html)) !== null) starts.push(m.index); |
| 210 | |
| 211 | let inner = ""; |
| 212 | for (let j = 0; j < starts.length; j++) { |
| 213 | const end = j + 1 < starts.length ? starts[j + 1] : html.length; |
| 214 | const chunk = html.slice(starts[j], end); |
| 215 | if (chunk.includes("initialData")) { |
| 216 | const startIdx = chunk.indexOf('[1,"'); |
| 217 | const endIdx = chunk.lastIndexOf('"]'); |
| 218 | if (startIdx !== -1 && endIdx !== -1) { |
| 219 | inner = chunk.slice(startIdx + 4, endIdx); |
| 220 | } |
| 221 | break; |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | let raw = inner |
| 226 | .replace(/\\"/g, '"') |
| 227 | .replace(/\\\\/g, '\\') |
| 228 | .replace(/\\n/g, '\n') |
| 229 | .replace(/\\r/g, ""); |
| 230 | raw = raw.replace(/[\x00-\x1f]/g, ""); |
| 231 | |
| 232 | const markerObj = '"initialData":{'; |
| 233 | const objIdx = raw.indexOf(markerObj); |
| 234 | if (objIdx !== -1) { |
| 235 | const objStart = objIdx + markerObj.length - 1; |
| 236 | let depth = 0; |
| 237 | for (let i = objStart; i < raw.length; i++) { |
| 238 | if (raw[i] === "{") depth++; |
| 239 | else if (raw[i] === "}") { |
| 240 | depth--; |
| 241 | if (depth === 0) { |
| 242 | try { |
| 243 | const obj = JSON.parse(raw.slice(objStart, i + 1)); |
| 244 | const ch = obj.chapter; |
| 245 | const s = obj.series?.[0]; |
| 246 | const images = ch?.data?.images || []; |
| 247 | return { |
| 248 | seriesTitle: s?.data?.title || cleanSlug, |
| 249 | slug: cleanSlug, |
| 250 | chapterIndex: ch?.chapterIndex || cleanCh, |
| 251 | title: ch?.data?.title || `Chapter ${cleanCh}`, |
| 252 | images, |
| 253 | url |
| 254 | }; |
| 255 | } catch {} |
| 256 | break; |
| 257 | } |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | throw new Error(`Gagal memuat chapter ${cleanCh} untuk ${cleanSlug}.`); |
| 263 | } |
| 264 | |
| 265 | export const voratoon = { |
| 266 | getUpdates, |
| 267 | getDetail, |
| 268 | getChapter |
| 269 | }; |
| 270 | |
| 271 | export default voratoon; |
| 272 | |
| 273 | async function runCLI() { |
| 274 | const args = process.argv.slice(2); |
| 275 | const isJson = args.includes("--json"); |
| 276 | const filteredArgs = args.filter((a) => a !== "--json"); |
| 277 | |
| 278 | if (filteredArgs.length === 0 || filteredArgs[0] === "--updates") { |
| 279 | const pageArg = filteredArgs.find((a) => a.startsWith("--page=")); |
| 280 | const page = pageArg ? parseInt(pageArg.split("=")[1], 10) : 1; |
| 281 | const results = await getUpdates({ page }); |
| 282 | if (isJson) { |
| 283 | console.log(JSON.stringify(results, null, 2)); |
| 284 | } else { |
| 285 | console.log(`Voratoon Latest Updates (Halaman ${page}):\n`); |
| 286 | results.forEach((item, i) => { |
| 287 | const latest = item.latestChapter ? `Ch. ${item.latestChapter.index}` : "No Ch"; |
| 288 | console.log(`${i + 1}. [${item.format.toUpperCase()}] ${item.title} (${latest}) - ${item.timeAgoText}`); |
| 289 | console.log(` Slug: ${item.slug} | Rating: ${item.rating} | Genres: ${item.genres}`); |
| 290 | console.log(` URL: ${item.url}\n`); |
| 291 | }); |
| 292 | } |
| 293 | return; |
| 294 | } |
| 295 | |
| 296 | if (filteredArgs[0] === "--chapter" || filteredArgs[0] === "-c") { |
| 297 | const slug = filteredArgs[1]; |
| 298 | const ch = filteredArgs[2]; |
| 299 | if (!slug || !ch) { |
| 300 | console.error("Penggunaan: node voratoon.js --chapter <slug> <nomor_chapter>"); |
| 301 | process.exit(1); |
| 302 | } |
| 303 | const result = await getChapter(slug, ch); |
| 304 | if (isJson) { |
| 305 | console.log(JSON.stringify(result, null, 2)); |
| 306 | } else { |
| 307 | console.log(`\n=== ${result.seriesTitle} - ${result.title} ===`); |
| 308 | console.log(`URL: ${result.url}`); |
| 309 | console.log(`Total Gambar: ${result.images.length}\n`); |
| 310 | result.images.forEach((img, idx) => { |
| 311 | console.log(`[${idx + 1}] ${img}`); |
| 312 | }); |
| 313 | } |
| 314 | return; |
| 315 | } |
| 316 | |
| 317 | let slug = filteredArgs[0]; |
| 318 | if (slug === "--detail" || slug === "-d") { |
| 319 | slug = filteredArgs[1]; |
| 320 | } |
| 321 | if (!slug) { |
| 322 | console.error("Penggunaan: node voratoon.js <slug|--updates|--chapter>"); |
| 323 | process.exit(1); |
| 324 | } |
| 325 | |
| 326 | const result = await getDetail(slug); |
| 327 | if (isJson) { |
| 328 | console.log(JSON.stringify(result, null, 2)); |
| 329 | } else { |
| 330 | console.log(`\n=== ${result.title} (${result.nativeTitle || "-"}) ===`); |
| 331 | console.log(`Author: ${result.author} | Status: ${result.status} | Rating: ${result.rating}`); |
| 332 | console.log(`Format: ${result.format.toUpperCase()} | Total Chapter: ${result.totalChapters}`); |
| 333 | console.log(`Genres: ${result.genres}`); |
| 334 | console.log(`URL: ${result.url}\n`); |
| 335 | console.log(`Sinopsis:\n${result.synopsis}\n`); |
| 336 | console.log(`Daftar Chapter (${result.allChapters.length} tersedia):`); |
| 337 | result.allChapters.slice(0, 10).forEach((ch) => { |
| 338 | console.log(` - ${ch.title}: ${ch.url}`); |
| 339 | }); |
| 340 | if (result.allChapters.length > 10) { |
| 341 | console.log(` ... dan ${result.allChapters.length - 10} chapter lainnya.`); |
| 342 | } |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { |
| 347 | runCLI().catch((err) => { |
| 348 | console.error("Error:", err.message); |
| 349 | process.exit(1); |
| 350 | }); |
| 351 | } |
| 352 | |
Discussion & Comments0
No comments yet — be the first to leave feedback or start a code discussion.
Sign in with your GitHub account to write comments and join the discussion.