Back to Home
Gistify Snippet
kyiov
1h ago·3 file(s)
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
Raw
voratoon-scraper.jsjavascript
352 lines
1import { fileURLToPath } from "url";
2
3const BASE = "https://v1.voratoon.com";
4const 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
10async 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
89function 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
101function 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
111function seriesURL(slug) {
112 return `${BASE}/series/${slug}`;
113}
114
115function chapterURL(slug, ch) {
116 return `${BASE}/series/${encodeURIComponent(slug)}/chapter/${encodeURIComponent(ch)}`;
117}
118
119function 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
157export 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
187export 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
196export 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
265export const voratoon = {
266 getUpdates,
267 getDetail,
268 getChapter
269};
270
271export default voratoon;
272
273async 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
346if (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.