informasi hotel, jadwal kereta, jadwal pesawat serta harga
haloo mas gienetic
1/**
2 * HOTELMURAH.COM - HOTEL/KERETA/PESAWAT
3 * Base : https://www.hotelmurah.com
4 * Author : Gienetic
5 * Request : YOGIKID
6 * Note : Orkut & Topup nya? BAYAR :V
7 */
8
9const BASE = 'https://www.hotelmurah.com';
10const UA = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Mobile Safari/537.36 Gienetic/KryzenTeam';
11
12const formatRupiah = new Intl.NumberFormat('id-ID', {
13 style: 'currency',
14 currency: 'IDR',
15 maximumFractionDigits: 0
16}).format;
17
18const HEADER_DASAR = {
19 'user-agent': UA,
20 accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
21 'accept-language': 'id,en-US;q=0.9',
22 'x-requested-with': 'Gienetic-KryzenTeam',
23};
24
25let DAFTAR_STASIUN = [];
26let DAFTAR_BANDARA = [];
27
28const BANDARA_FALLBACK = [
29 ['BPN', 4, 'Sepinggan - BalikPapan'],
30 ['BDO', 6, 'Husein Sastranegara - Bandung'],
31 ['BTH', 9, 'Hang Nadim - Batam'],
32 ['DPS', 18, 'Ngurah Rai - Denpasar, Bali'],
33 ['CGK', 24, 'Soekarno Hatta - Jakarta'],
34 ['HLP', 25, 'Halim Perdanakusuma - Jakarta'],
35 ['MDC', 42, 'Sam Ratulangi - Manado'],
36 ['KNO', 45, 'Kuala Namu - Medan (Kuala Namu)'],
37 ['UPG', 94, 'Sultan Hasanuddin - Ujungpandang, Makassar'],
38 ['JOG', 99, 'Adi Sutjipto - Yogyakarta'],
39].map(([kode, id, url]) => ({
40 kode,
41 id,
42 url,
43 nama: url.split(' - ')[0],
44 kota: url.split(' - ')[1] || ''
45}));
46
47// ─────────── Utilitas ───────────
48
49const tunggu = (ms) => new Promise((r) => setTimeout(r, ms));
50
51function dekodeEntitas(s = '') {
52 return String(s)
53 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
54 .replace(/"/g, '"').replace(/�?39;|'/g, "'").replace(/ /g, ' ')
55 .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n));
56}
57
58const hapusTag = (s = '') => String(s).replace(/<[^>]+>/g, '');
59const bersihkan = (s = '') => dekodeEntitas(hapusTag(s)).replace(/\s+/g, ' ').trim();
60
61function hariIni() {
62 const d = new Date();
63 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
64}
65
66function tambahHari(tgl, n) {
67 const [y, m, d] = tgl.split('-').map(Number);
68 const t = new Date(y, m - 1, d + n);
69 return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
70}
71
72const tanggalValid = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !isNaN(new Date(s).getTime());
73const angka = (v) => {
74 const n = parseInt(String(v).replace(/[^\d-]/g, ''), 10);
75 return Number.isFinite(n) ? n : 0;
76};
77const tampilkan = (obj) => console.log(JSON.stringify(JSON.parse(JSON.stringify(obj)), null, 2));
78const diBlokir = (html) => /_cf_chl_opt|cf-browser-verification/.test(html);
79
80// ─────────── Lapisan HTTP ───────────
81
82async function permintaan(url, opsi = {}) {
83 const r = await fetch(url, {
84 method: opsi.method || 'GET',
85 headers: {
86 ...HEADER_DASAR,
87 ...opsi.headers
88 },
89 body: opsi.body,
90 signal: AbortSignal.timeout(opsi.timeout || 20000),
91 });
92 if (!r.ok) {
93 const e = new Error(`HTTP ${r.status}`);
94 e.status = r.status;
95 throw e;
96 }
97 return r.text();
98}
99
100const ambil = (url, headers) => permintaan(url, {
101 headers
102});
103const kirim = (url, body, headers) => permintaan(url, {
104 method: 'POST',
105 body,
106 headers: {
107 'content-type': 'application/x-www-form-urlencoded',
108 origin: BASE,
109 'x-requested-with': 'XMLHttpRequest',
110 ...headers
111 },
112});
113
114// ─────────── Stasiun & Bandara ───────────
115
116async function ambilDaftarStasiun() {
117 if (DAFTAR_STASIUN.length) return DAFTAR_STASIUN;
118 const html = await ambil(`${BASE}/tiketkeretaapi/`, {
119 referer: `${BASE}/tiketkeretaapi/`
120 });
121 const seen = new Set();
122 for (const m of html.matchAll(/ data-kode1="([^"]+)" data-kota1="([^"]+)" data-stasiun1="([^"]+)"/g)) {
123 if (seen.has(m[1])) continue;
124 seen.add(m[1]);
125 DAFTAR_STASIUN.push({
126 kode: m[1],
127 kota: bersihkan(m[2]).replace(/^Kota /, ''),
128 nama: bersihkan(m[3]).split(' (')[0],
129 });
130 }
131 return DAFTAR_STASIUN;
132}
133
134async function ambilDaftarBandara() {
135 if (DAFTAR_BANDARA.length) return DAFTAR_BANDARA;
136 try {
137 const html = await ambil(`${BASE}/tiketpesawat/`, {
138 referer: `${BASE}/tiketpesawat/`
139 });
140 const map = new Map();
141
142 for (const m of html.matchAll(/data-id="(\d+)"\s+data-tipe="airport"\s+data-url="([^"]+)"[^>]*>([^<]+)<\/div>/g)) {
143 const kode = (m[3].match(/\(([A-Z]{3})\)/) || [])[1] || null;
144 const url = dekodeEntitas(m[2]);
145 map.set(`ID${m[1]}`, {
146 kode,
147 id: +m[1],
148 url,
149 nama: url.split(' - ')[0],
150 kota: url.split(' - ')[1] || ''
151 });
152 }
153
154 for (const m of html.matchAll(/[?&]ke=([^&"]+)[^"]*?[?&]kti=(\d+)/g)) {
155 const id = +m[2];
156 if (map.has(`ID${id}`)) continue;
157 const url = decodeURIComponent(m[1]).replace(/\+/g, ' ');
158 map.set(`ID${id}`, {
159 kode: null,
160 id,
161 url,
162 nama: url.split(' - ')[0],
163 kota: url.split(' - ')[1] || ''
164 });
165 }
166
167 DAFTAR_BANDARA = [...map.values()].sort((a, b) => a.id - b.id);
168 if (!DAFTAR_BANDARA.length) DAFTAR_BANDARA = [...BANDARA_FALLBACK];
169 } catch {
170 DAFTAR_BANDARA = [...BANDARA_FALLBACK];
171 }
172 return DAFTAR_BANDARA;
173}
174
175function cariStasiun(q) {
176 const u = q.toUpperCase();
177 return DAFTAR_STASIUN.find((s) => s.kode === u || s.nama.toUpperCase().includes(u) || s.kota.toUpperCase().includes(u));
178}
179
180function cariBandara(q) {
181 const u = q.toUpperCase();
182 return DAFTAR_BANDARA.find((b) =>
183 (b.kode && b.kode === u) ||
184 b.nama.toUpperCase().includes(u) ||
185 (b.kota && b.kota.toUpperCase().includes(u))
186 );
187}
188
189const saranStasiun = (q) => {
190 const u = q.toUpperCase();
191 return DAFTAR_STASIUN
192 .filter((s) => s.kode.startsWith(u) || s.nama.toUpperCase().includes(u) || s.kota.toUpperCase().includes(u))
193 .slice(0, 5);
194};
195
196const saranBandara = (q) => {
197 const u = q.toUpperCase();
198 return DAFTAR_BANDARA
199 .filter((b) => (b.kode && b.kode.startsWith(u)) || b.nama.toUpperCase().includes(u) || (b.kota && b.kota.toUpperCase().includes(u)))
200 .slice(0, 5);
201};
202
203// ─────────── Hotel: Daftar ───────────
204
205function parseDaftarHotel(html) {
206 const hotels = [];
207 for (const blok of html.split('<div class="_Pd10">').slice(1)) {
208 const kartu = blok.split('<!--')[0];
209
210 const href = (kartu.match(/<a\s+href="([^"]+)"[^>]*class="box-hotel"/) || [])[1] || '';
211 const po = kartu.match(/poclick\('(\d+)'\s*,\s*'([\s\S]*?)'\)/);
212 const idM = kartu.match(/id="id-hotel-(\d+)"/);
213 const namaM = kartu.match(/<div class="name-content"[^>]*>([\s\S]*?)<\/div>/);
214
215 const id = idM?.[1] || po?.[1];
216 const nama = namaM ? bersihkan(namaM[1]) : po ? dekodeEntitas(po[2]) : '';
217 if (!id || !nama) continue;
218
219 const path = dekodeEntitas(href).match(/\/hotel\/([^/]+)\/([^/?]+)\/?/);
220 const kota = path?.[1] || '';
221 let slug = path?.[2] || '';
222 const kunci = (kartu.match(/id="([^"]+)-(?:pricetag|kupon)"/) || [])[1] || slug;
223 if (!slug) slug = kunci;
224
225 const alamatM = kartu.match(/<div class="name-address">([\s\S]*?)<\/div>/);
226 let alamat = '';
227 if (alamatM) {
228 const t = bersihkan(alamatM[1]);
229 alamat = t.includes('•') ? t.split('•').pop().trim() : t.trim();
230 }
231
232 hotels.push({
233 id,
234 nama,
235 slug,
236 kota,
237 kunciHarga: kunci,
238 bintang: (kartu.match(/<i class="fa fa-star"/g) || []).length,
239 rating: parseFloat((kartu.match(/rating-review-score">([\d.]+)</) || [])[1]) || 0,
240 alamat,
241 gambar: (kartu.match(/<img\s+src="([^"]+)"\s+alt="/) || [])[1] || '',
242 urlDetail: kota && slug ? `${BASE}/hotel/${kota}/${slug}/` : '',
243 urlPemesanan: dekodeEntitas(href),
244 });
245 }
246 return hotels;
247}
248
249async function ambilHarga(html, referer, paralel = 4) {
250 const map = new Map();
251 const m = html.match(/listHotel\s*=\s*'(\[[\s\S]*?\])'/);
252 if (!m) return map;
253
254 let payloads;
255 try {
256 payloads = JSON.parse(m[1]);
257 } catch {
258 return map;
259 }
260
261 for (let i = 0; i < payloads.length; i += paralel) {
262 const batch = payloads.slice(i, i + paralel);
263 const hasil = await Promise.allSettled(
264 batch.map((p) => kirim(`${BASE}/bestprices`, 'data=' + encodeURIComponent(p), {
265 referer
266 }))
267 );
268 for (const r of hasil) {
269 if (r.status !== 'fulfilled') continue;
270 try {
271 const o = JSON.parse(r.value);
272 if (o?.kl) map.set(o.kl, o);
273 } catch {
274 /* lewati */ }
275 }
276 }
277 return map;
278}
279
280function gabungHarga(hotel, data, mentah) {
281 const harga = angka(data?.sellPrice);
282 const hargaPajak = angka(data?.sellPriceWTax);
283 const hargaPublik = angka(data?.webPrice);
284 const habis = data?.soldout == 1;
285
286 Object.assign(hotel, {
287 status: habis ? 'soldout' : harga ? 'available' : 'unavailable',
288 harga: harga || null,
289 hargaTeks: harga ? formatRupiah(harga) : null,
290 hargaTermasukPajak: hargaPajak || undefined,
291 hargaTermasukPajakTeks: hargaPajak ? formatRupiah(hargaPajak) : undefined,
292 pajak: hargaPajak > harga ? hargaPajak - harga : undefined,
293 hargaPublik: hargaPublik || undefined,
294 diskon: hargaPublik > harga && harga ? hargaPublik - harga : undefined,
295 persenDiskon: hargaPublik > harga && harga ? Math.round((hargaPublik - harga) / hargaPublik * 100) : undefined,
296 habis: habis || undefined,
297 promo: data?.promo == 1 || undefined,
298 tagPromo: data?.promoTag || undefined,
299 favorit: data?.favorit == 1 || undefined,
300 });
301 if (mentah) hotel.mentah = data || null;
302 return hotel;
303}
304
305function urutkan(arr, kunci) {
306 const a = [...arr];
307 if (kunci === 'harga') return a.sort((x, y) => (x.harga || Infinity) - (y.harga || Infinity));
308 if (kunci === 'harga-desc') return a.sort((x, y) => (y.harga || 0) - (x.harga || 0));
309 if (kunci === 'rating') return a.sort((x, y) => y.rating - x.rating);
310 if (kunci === 'bintang') return a.sort((x, y) => y.bintang - x.bintang);
311 if (kunci === 'nama') return a.sort((x, y) => x.nama.localeCompare(y.nama, 'id'));
312 return a;
313}
314
315async function cariHotel(kota, cin, cout, opsi = {}) {
316 const dewasa = opsi.dewasa ?? 1;
317 const anak = opsi.anak ?? 1;
318 const slugKota = encodeURIComponent(kota.toLowerCase());
319 const referer = `${BASE}/hotel/${slugKota}/`;
320 const lihat = new Set();
321 const semua = [];
322 const sumber = [];
323 let halamanDiambil = 0;
324
325 for (let h = 1; h <= (opsi.maxHalaman || 1); h++) {
326 const params = new URLSearchParams({
327 cin,
328 cout,
329 a: dewasa,
330 c: anak,
331 mode: 'strict'
332 });
333 if (h > 1) params.set('per_page', String((h - 1) * 10));
334 const url = `${BASE}/hotel/${slugKota}/?${params}`;
335 sumber.push(url);
336
337 const html = await ambil(url, {
338 referer
339 });
340 if (diBlokir(html)) throw new Error('Diblokir Cloudflare. Coba lagi atau gunakan VPN.');
341
342 const hotels = parseDaftarHotel(html);
343 if (!hotels.length) break;
344
345 halamanDiambil++;
346 const peta = await ambilHarga(html, url, opsi.paralel);
347 for (const x of hotels) {
348 if (lihat.has(x.id)) continue;
349 lihat.add(x.id);
350 gabungHarga(x, peta.get(x.kunciHarga), opsi.mentah);
351 semua.push(x);
352 }
353 if (hotels.length < 10) break;
354 }
355 return {
356 hotels: semua,
357 sumber,
358 halamanDiambil
359 };
360}
361
362function buatLaporanHotel(hotels, query, sumber, halaman, kunciSort) {
363 const tersedia = hotels.filter((h) => h.harga > 0);
364 const harga = tersedia.map((h) => h.harga).sort((a, b) => a - b);
365 const rentangHarga = harga.length ? {
366 min: harga[0],
367 minTeks: formatRupiah(harga[0]),
368 maks: harga[harga.length - 1],
369 maksTeks: formatRupiah(harga[harga.length - 1]),
370 rataRata: Math.round(harga.reduce((a, b) => a + b, 0) / harga.length),
371 rataRataTeks: formatRupiah(Math.round(harga.reduce((a, b) => a + b, 0) / harga.length)),
372 } : null;
373
374 return {
375 status: hotels.length ? 'success' : 'not_found',
376 pesan: hotels.length ? undefined : `Tidak ada hotel di ${query.kota} pada tanggal tersebut.`,
377 mataUang: 'IDR',
378 query,
379 paginasi: {
380 halaman,
381 perHalaman: 10
382 },
383 urutan: kunciSort || 'default',
384 ringkasan: {
385 total: hotels.length,
386 tersedia: tersedia.length,
387 habis: hotels.filter((h) => h.habis).length,
388 promo: hotels.filter((h) => h.promo).length,
389 favorit: hotels.filter((h) => h.favorit).length,
390 rentangHarga,
391 },
392 sumber,
393 dibuatPada: new Date().toISOString(),
394 hasil: hotels,
395 };
396}
397
398// ─────────── Hotel: Detail ───────────
399
400async function detailHotel(input, cin, cout, opsi = {}) {
401 const dewasa = opsi.dewasa ?? 1;
402 const anak = opsi.anak ?? 1;
403 const [kota, ...sisa] = input.split('/');
404 const slug = sisa.length ? sisa.join('/') : kota;
405 const url = sisa.length ? `${BASE}/hotel/${kota}/${slug}/` : `${BASE}/hotel/${slug}/`;
406 const urlPenuh = `${url}?${new URLSearchParams({ cin, cout, a: dewasa, c: anak })}`;
407
408 let html;
409 try {
410 html = await ambil(urlPenuh, {
411 referer: `${BASE}/hotel/`
412 });
413 } catch (e) {
414 if (e.status === 404) return {
415 status: 'not_found',
416 pesan: `Hotel "${input}" tidak ditemukan.`
417 };
418 throw e;
419 }
420
421 if (diBlokir(html)) throw new Error('Diblokir Cloudflare.');
422
423 const namaM = html.match(/<h1 class="namehm-address">([^<]+)<\//);
424 if (!namaM) return {
425 status: 'not_found',
426 pesan: `Hotel "${input}" tidak ditemukan.`
427 };
428
429 const bintang = (html.match(/<div class="rating-address">([\s\S]*?)<\/div>/)?.[1]?.match(/<i class="fas fa-star txkuning"/g) || []).length;
430 const fasilitas = [...html.matchAll(/<div class="fass-slide">[\s\S]*?<div style="white-space: pre-line;">([^<]+)<\//g)].map((m) => bersihkan(m[1]));
431 const galeri = [...new Set([...html.matchAll(/data-src="(https:\/\/(?:img\.hotelmurah\.com|q-xx\.bstatic\.com|pix8\.agoda\.net)[^"]+)"/gi)].map((m) => m[1]))];
432 const ulasan = [...html.matchAll(/<div class="alg-review"[^>]*>[\s\S]*?<div class="tx-review">\s*"([^"]+)"[\s\S]*?<span>([^<]+)<\/span>[\s\S]*?<span>(\d+)\/10<\/span>/g)]
433 .map((m) => ({
434 nama: bersihkan(m[2]),
435 skor: +m[3],
436 teks: bersihkan(m[1])
437 }));
438
439 let harga = null;
440 try {
441 const peta = await ambilHarga(html, urlPenuh, opsi.paralel);
442 const objs = [...peta.values()].filter((o) => angka(o.sellPrice) > 0).sort((a, b) => angka(a.sellPrice) - angka(b.sellPrice));
443 if (objs.length) {
444 const jual = angka(objs[0].sellPrice);
445 const pajak = angka(objs[0].sellPriceWTax);
446 harga = {
447 hargaMulai: jual,
448 hargaMulaiTeks: formatRupiah(jual),
449 hargaMulaiTermasukPajak: pajak || undefined,
450 hargaMulaiTermasukPajakTeks: pajak ? formatRupiah(pajak) : undefined,
451 jumlahKamar: peta.size,
452 };
453 }
454 } catch {
455 /* lewati */ }
456
457 const lat = html.match(/var latitude = '([^']+)'/);
458 const lng = html.match(/var longitude = '([^']+)'/);
459
460 return {
461 status: 'success',
462 nama: bersihkan(namaM[1]),
463 slug,
464 kota: sisa.length ? kota : undefined,
465 bintang,
466 rating: parseFloat(html.match(/<p class="box-rating">([\d.]+)<\//)?.[1]) || 0,
467 labelRating: bersihkan(html.match(/<p class="rt-text">([^<]+)<\//)?.[1] || ''),
468 jumlahUlasan: bersihkan(html.match(/<p class="rv-text">([^<]+)<\//)?.[1] || ''),
469 alamat: bersihkan(html.match(/<span>([^<]+)<\/span>\s*<\/div>\s*<\/div>\s*<div class="deskripsi-hm"/)?.[1] || ''),
470 lokasi: lat && lng ? {
471 lat: parseFloat(lat[1]),
472 lng: parseFloat(lng[1])
473 } : null,
474 deskripsi: bersihkan(html.match(/<meta name="description" content="([^"]+)">/)?.[1] || ''),
475 harga,
476 fasilitas,
477 galeri,
478 ulasan,
479 urlDetail: url,
480 };
481}
482
483// ─────────── Kereta ───────────
484
485async function cariKereta(asal, tujuan, tanggal, opsi = {}) {
486 const params = new URLSearchParams({
487 dari: asal.kode,
488 ke: tujuan.kode,
489 checkin: tanggal,
490 checkout: tambahHari(tanggal, 1),
491 pp: opsi.pp || 0,
492 dewasa: opsi.dewasa || 1,
493 bayi: opsi.bayi || 0,
494 stasiun_asal: `${asal.nama} (${asal.kode})`,
495 stasiun_tujuan: `${tujuan.nama} (${tujuan.kode})`,
496 });
497 const html = await ambil(`${BASE}/tiketkeretaapi/kereta/list_kereta?${params}`, {
498 referer: `${BASE}/tiketkeretaapi/`
499 });
500 if (diBlokir(html)) throw new Error('Diblokir Cloudflare.');
501
502 const m = html.match(/var list_result_data = JSON\.parse\('({.*?})'\);/);
503 const list = m ? JSON.parse(m[1]).pergi || [] : [];
504 if (!list.length) {
505 return {
506 status: 'not_found',
507 pesan: `Tidak ada kereta ${asal.kode} → ${tujuan.kode} pada ${tanggal}.`,
508 query: {
509 dari: asal,
510 ke: tujuan,
511 tanggal
512 },
513 };
514 }
515
516 const lihat = new Set();
517 const jadwal = list
518 .filter((k) => {
519 const key = `${k.trainNo}|${k.classCategory}|${k.fare}`;
520 if (lihat.has(key)) return false;
521 lihat.add(key);
522 return true;
523 })
524 .map((k) => ({
525 namaKereta: bersihkan(k.trainName),
526 nomorKereta: k.trainNo,
527 kelas: k.classCategory,
528 berangkat: k.departureTime,
529 tiba: k.arrivalTime,
530 kursiTersedia: +k.seatAvail || 0,
531 habis: +k.seatAvail > 0 ? undefined : true,
532 tarif: +k.fare || 0,
533 tarifTeks: formatRupiah(+k.fare || 0),
534 }));
535
536 const tarif = jadwal.filter((j) => j.tarif > 0).map((j) => j.tarif).sort((a, b) => a - b);
537 return {
538 status: 'success',
539 query: {
540 dari: asal,
541 ke: tujuan,
542 tanggal
543 },
544 ringkasan: {
545 total: jadwal.length,
546 tersedia: jadwal.filter((j) => !j.habis).length,
547 rentangTarif: tarif.length ? {
548 min: tarif[0],
549 minTeks: formatRupiah(tarif[0]),
550 maks: tarif[tarif.length - 1],
551 maksTeks: formatRupiah(tarif[tarif.length - 1]),
552 } : null,
553 },
554 dibuatPada: new Date().toISOString(),
555 hasil: jadwal,
556 };
557}
558
559// ─────────── Pesawat ───────────
560
561async function cariPesawat(asal, tujuan, tanggal, opsi = {}) {
562 const params = new URLSearchParams({
563 dari: asal.url,
564 ke: tujuan.url,
565 tgl_berangkat: tanggal,
566 tgl_kembali: 0,
567 pp: 'no',
568 dewasa: opsi.dewasa || 1,
569 anak: opsi.anak || 0,
570 bayi: opsi.bayi || 0,
571 kai: asal.id,
572 kti: tujuan.id,
573 });
574 const html = await ambil(`${BASE}/tiketpesawat/flight/result?${params}`, {
575 referer: `${BASE}/tiketpesawat/`
576 });
577 if (diBlokir(html)) throw new Error('Diblokir Cloudflare.');
578
579 const m = html.match(/var list_result_data = JSON\.parse\('({.*?})'\);/);
580 const list = m ? JSON.parse(m[1]).berangkat || [] : [];
581 if (!list.length) {
582 return {
583 status: 'not_found',
584 pesan: `Tidak ada penerbangan ${asal.kode || asal.id} → ${tujuan.kode || tujuan.id} pada ${tanggal}.`,
585 query: {
586 dari: asal,
587 ke: tujuan,
588 tanggal
589 },
590 };
591 }
592
593 const lihat = new Set();
594 const flight = list
595 .filter((f) => {
596 const k = `${f.airlinecode}|${f.flightno}|${f.fare}`;
597 if (lihat.has(k)) return false;
598 lihat.add(k);
599 return true;
600 })
601 .map((f) => {
602 const dep = f.penerbangan[0];
603 const arr = f.penerbangan[f.penerbangan.length - 1];
604 return {
605 maskapai: bersihkan(f.maskapai),
606 kodeMaskapai: f.airlinecode,
607 nomorPenerbangan: f.flightno,
608 kabin: f.cabin,
609 dari: dep.from_airport,
610 ke: arr.to_airport,
611 berangkat: dep.departtime,
612 tiba: arr.arrivetime,
613 durasi: `${f.durationhour}j ${f.durationminute}m`,
614 durasiMenit: (+f.durationhour || 0) * 60 + (+f.durationminute || 0),
615 transit: +f.jml_transit || 0,
616 bagasiKg: +f.baggagekilos || 0,
617 tarif: +f.fare || 0,
618 tarifTeks: formatRupiah(+f.fare || 0),
619 diskonPromo: +f.diskon_promo || 0,
620 diskonPromoTeks: +f.diskon_promo > 0 ? formatRupiah(+f.diskon_promo) : undefined,
621 };
622 });
623
624 const tarif = flight.filter((f) => f.tarif > 0).map((f) => f.tarif).sort((a, b) => a - b);
625 return {
626 status: 'success',
627 query: {
628 dari: asal,
629 ke: tujuan,
630 tanggal
631 },
632 ringkasan: {
633 total: flight.length,
634 langsung: flight.filter((f) => !f.transit).length,
635 denganTransit: flight.filter((f) => f.transit).length,
636 rentangTarif: tarif.length ? {
637 min: tarif[0],
638 minTeks: formatRupiah(tarif[0]),
639 maks: tarif[tarif.length - 1],
640 maksTeks: formatRupiah(tarif[tarif.length - 1]),
641 } : null,
642 },
643 dibuatPada: new Date().toISOString(),
644 hasil: flight,
645 };
646}
647
648// ─────────── CLI ───────────
649
650function parseOpsi(args) {
651 const o = {
652 _: [],
653 maxHalaman: 1,
654 paralel: 4
655 };
656 const num = (v) => {
657 const n = +v;
658 return Number.isFinite(n) ? n : null;
659 };
660 for (let i = 0; i < args.length; i++) {
661 const k = args[i];
662 if (k === '--dewasa') o.dewasa = num(args[++i]);
663 else if (k === '--anak') o.anak = num(args[++i]);
664 else if (k === '--bayi') o.bayi = num(args[++i]);
665 else if (k === '--pp') o.pp = ['ya', 'yes', '1', 'true'].includes(args[++i]?.toLowerCase()) ? 1 : 0;
666 else if (k === '--max-halaman' || k === '--max-page') o.maxHalaman = Math.max(1, num(args[++i]) || 1);
667 else if (k === '--limit') o.limit = Math.max(1, num(args[++i]) || 0);
668 else if (k === '--urut' || k === '--sort') o.urut = args[++i]?.toLowerCase();
669 else if (k === '--mentah' || k === '--raw') o.mentah = true;
670 else if (k === '--paralel' || k === '--concurrency') o.paralel = Math.max(1, Math.min(8, num(args[++i]) || 4));
671 else o._.push(k);
672 }
673 return o;
674}
675
676const BANTUAN = `
677hotelmurah.com — CLI
678
679PERINTAH
680 hotel node hotelmurah.js hotel <kota> [cin] [cout] [opsi]
681 detail node hotelmurah.js detail <kota/slug> [cin] [cout]
682 kereta node hotelmurah.js kereta <dari> <ke> [tgl]
683 pesawat node hotelmurah.js pesawat <dari> <ke> [tgl]
684 bandara node hotelmurah.js --list-bandara
685 stasiun node hotelmurah.js --list-stasiun
686
687OPSI TAMU
688 --dewasa <n> jumlah dewasa (default 1)
689 --anak <n> jumlah anak (hotel/pesawat)
690 --bayi <n> jumlah bayi (kereta/pesawat)
691 --pp ya|no pulang-pergi (kereta)
692
693OPSI HOTEL
694 --max-halaman <n> halaman yang di-scrape (10 hotel/halaman, default 1)
695 --limit <n> batasi jumlah hotel di output
696 --urut <k> harga | harga-desc | rating | bintang | nama
697 --mentah sertakan objek harga mentah dari server
698 --paralel <1-8> permintaan harga paralel (default 4)
699
700TANGGAL
701 Opsional. Default:
702 hotel cin = besok, cout = besok+1
703 kereta tgl = hari ini
704 pesawat tgl = hari ini
705 Format: YYYY-MM-DD
706
707CONTOH
708 node hotelmurah.js hotel jakarta
709 node hotelmurah.js hotel bandung 2026-06-20 2026-06-21 --max-halaman 3 --urut harga
710 node hotelmurah.js detail jakarta/park-hyatt-jakarta-203-7
711 node hotelmurah.js kereta GMR BD --dewasa 2 --bayi 1
712 node hotelmurah.js pesawat CGK DPS --dewasa 2 --anak 1
713 node hotelmurah.js --list-stasiun
714 node hotelmurah.js --list-bandara
715`;
716
717
718function tentukanTanggalHotel(p) {
719 let cin = p[2];
720 let cout = p[3];
721 if (cin && !tanggalValid(cin)) return {
722 error: `Tanggal "${cin}" tidak valid (YYYY-MM-DD).`
723 };
724 if (cout && !tanggalValid(cout)) return {
725 error: `Tanggal "${cout}" tidak valid (YYYY-MM-DD).`
726 };
727 cin = cin || tambahHari(hariIni(), 1);
728 cout = cout || tambahHari(cin, 1);
729 if (cout <= cin) return {
730 error: 'Check-out harus setelah check-in.'
731 };
732 return {
733 cin,
734 cout
735 };
736}
737
738async function utama() {
739 const args = process.argv.slice(2);
740 if (!args.length || ['-h', '--help', '--bantuan'].includes(args[0])) return console.log(BANTUAN);
741
742 if (args[0] === '--list-bandara' || args[0] === '--list-pesawat') {
743 await ambilDaftarBandara();
744 DAFTAR_BANDARA.forEach((b) => console.log(`${(b.kode || '---').padEnd(4)} ${String(b.id).padStart(3)} ${b.url}`));
745 return;
746 }
747
748 if (args[0] === '--list-stasiun' || args[0] === '--list-kereta') {
749 await ambilDaftarStasiun();
750 DAFTAR_STASIUN.forEach((s) => console.log(`${s.kode.padEnd(5)} ${s.nama} - ${s.kota}`));
751 return;
752 }
753
754 const opsi = parseOpsi(args);
755 const [mode, ...p] = opsi._;
756 p.unshift(mode);
757
758 try {
759 if (mode === 'hotel') {
760 if (!p[1]) return console.log(BANTUAN);
761 const tgl = tentukanTanggalHotel(p);
762 if (tgl.error) return tampilkan({
763 status: 'error',
764 pesan: tgl.error
765 });
766
767 const hasil = await cariHotel(p[1], tgl.cin, tgl.cout, opsi);
768 let hotels = urutkan(hasil.hotels, opsi.urut);
769 if (opsi.limit) hotels = hotels.slice(0, opsi.limit);
770
771 tampilkan(buatLaporanHotel(
772 hotels, {
773 kota: p[1],
774 checkin: tgl.cin,
775 checkout: tgl.cout,
776 dewasa: opsi.dewasa ?? 1,
777 anak: opsi.anak ?? 1
778 },
779 hasil.sumber,
780 hasil.halamanDiambil,
781 opsi.urut,
782 ));
783 } else if (mode === 'detail') {
784 if (!p[1]) return console.log(BANTUAN);
785 const tgl = tentukanTanggalHotel(p);
786 if (tgl.error) return tampilkan({
787 status: 'error',
788 pesan: tgl.error
789 });
790
791 const data = await detailHotel(p[1], tgl.cin, tgl.cout, opsi);
792 tampilkan({
793 ...data,
794 query: {
795 slug: p[1],
796 checkin: tgl.cin,
797 checkout: tgl.cout
798 },
799 dibuatPada: new Date().toISOString()
800 });
801 } else if (mode === 'kereta') {
802 if (!p[1] || !p[2]) return console.log(BANTUAN);
803 const tgl = p[3] || hariIni();
804 if (p[3] && !tanggalValid(tgl)) return tampilkan({
805 status: 'error',
806 pesan: `Tanggal tidak valid.`
807 });
808
809 await ambilDaftarStasiun();
810 const asal = cariStasiun(p[1]);
811 const tujuan = cariStasiun(p[2]);
812 if (!asal) return tampilkan({
813 status: 'not_found',
814 pesan: `Stasiun "${p[1]}" tidak ditemukan.`,
815 saran: saranStasiun(p[1])
816 });
817 if (!tujuan) return tampilkan({
818 status: 'not_found',
819 pesan: `Stasiun "${p[2]}" tidak ditemukan.`,
820 saran: saranStasiun(p[2])
821 });
822
823 tampilkan(await cariKereta(asal, tujuan, tgl, opsi));
824 } else if (mode === 'pesawat') {
825 if (!p[1] || !p[2]) return console.log(BANTUAN);
826 const tgl = p[3] || hariIni();
827 if (p[3] && !tanggalValid(tgl)) return tampilkan({
828 status: 'error',
829 pesan: `Tanggal tidak valid.`
830 });
831
832 await ambilDaftarBandara();
833 const asal = cariBandara(p[1]);
834 const tujuan = cariBandara(p[2]);
835 if (!asal) return tampilkan({
836 status: 'not_found',
837 pesan: `Bandara "${p[1]}" tidak ditemukan.`,
838 saran: saranBandara(p[1])
839 });
840 if (!tujuan) return tampilkan({
841 status: 'not_found',
842 pesan: `Bandara "${p[2]}" tidak ditemukan.`,
843 saran: saranBandara(p[2])
844 });
845
846 tampilkan(await cariPesawat(asal, tujuan, tgl, opsi));
847 } else {
848 console.log(BANTUAN);
849 }
850 } catch (e) {
851 tampilkan({
852 status: 'error',
853 pesan: e.message
854 });
855 process.exitCode = 1;
856 }
857}
858
859if (require.main === module) utama();
860
861module.exports = {
862 cariHotel,
863 detailHotel,
864 cariKereta,
865 cariPesawat,
866 parseDaftarHotel,
867 ambilHarga,
868 gabungHarga,
869 urutkan,
870 buatLaporanHotel,
871 ambilDaftarStasiun,
872 ambilDaftarBandara,
873 cariStasiun,
874 cariBandara,
875};