Ayogiakbar

Hotelmurah

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