indo-scraper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,472 @@
1
+ const { axios, cheerio, ok, fail } = require('../utils')
2
+ const vm = require('vm')
3
+
4
+ // ── Helpers ────────────────────────────────────────────────────────────────
5
+
6
+ const SNAPTIK_HEADERS = {
7
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.82 Mobile Safari/537.36',
8
+ 'Accept-Language': 'id-ID,id;q=0.9',
9
+ }
10
+
11
+ // Resolve short URL → dapatkan URL penuh + video ID
12
+ async function resolveUrl(url) {
13
+ try {
14
+ const res = await axios.get(url, {
15
+ headers: SNAPTIK_HEADERS,
16
+ maxRedirects: 10,
17
+ timeout: 15000,
18
+ })
19
+ const finalUrl = res.request?.res?.responseUrl || res.config?.url || url
20
+ const match = finalUrl.match(/video\/(\d+)/)
21
+ return { url: finalUrl, id: match?.[1] || null }
22
+ } catch (e) {
23
+ const match = url.match(/video\/(\d+)/)
24
+ return { url, id: match?.[1] || null }
25
+ }
26
+ }
27
+
28
+ // Submit URL ke snaptik dan kembalikan HTML response
29
+ async function snaptikFetch(url) {
30
+ const pageRes = await axios.get('https://snaptik.app/ID2', {
31
+ headers: SNAPTIK_HEADERS,
32
+ timeout: 15000,
33
+ })
34
+ const $page = cheerio.load(pageRes.data)
35
+ const token = $page('input[name="token"]').val()
36
+ || $page('form input[type="hidden"]').first().val()
37
+
38
+ if (!token) throw new Error('Gagal mendapatkan token snaptik')
39
+
40
+ const formRes = await axios.post(
41
+ 'https://snaptik.app/abc2.php',
42
+ new URLSearchParams({ url, token }).toString(),
43
+ {
44
+ headers: {
45
+ ...SNAPTIK_HEADERS,
46
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
47
+ 'Referer': 'https://snaptik.app/ID2',
48
+ 'Origin': 'https://snaptik.app',
49
+ 'X-Requested-With': 'XMLHttpRequest',
50
+ },
51
+ timeout: 30000,
52
+ }
53
+ )
54
+ return formRes.data
55
+ }
56
+
57
+ // Decode multi-level eval obfuscation
58
+ function decodeSnaptik(rawData) {
59
+ if (typeof rawData !== 'string') return null
60
+ if (rawData.includes('<a ') && rawData.includes('http')) return rawData
61
+
62
+ const capturedHtmls = {}
63
+ let foundHtml = null
64
+
65
+ const runInSandbox = (code, depth = 0) => {
66
+ if (depth > 4 || foundHtml) return
67
+ const sandbox = {
68
+ decodeURIComponent, encodeURIComponent, escape, unescape,
69
+ String, Math, parseInt, parseFloat, JSON, Array, Object,
70
+ RegExp, Boolean, Number, isNaN, isFinite,
71
+ window: { location: { hostname: 'snaptik.app', href: 'https://snaptik.app/ID2' } },
72
+ location: { hostname: 'snaptik.app' },
73
+ navigator: { userAgent: 'Mozilla/5.0' },
74
+ document: {
75
+ getElementById: (id) => ({
76
+ set innerHTML(v) { capturedHtmls[id] = v; if (v.includes('<a ') && v.includes('http')) foundHtml = v },
77
+ get innerHTML() { return capturedHtmls[id] || '' },
78
+ style: {},
79
+ }),
80
+ querySelector: (sel) => ({
81
+ set innerHTML(v) { capturedHtmls[sel] = v; if (v.includes('<a ') && v.includes('http')) foundHtml = v },
82
+ get innerHTML() { return capturedHtmls[sel] || '' },
83
+ style: {},
84
+ }),
85
+ createElement: () => ({ innerHTML: '', style: {}, setAttribute: () => {}, appendChild: () => {} }),
86
+ body: { appendChild: () => {}, innerHTML: '' },
87
+ },
88
+ eval: (s) => {
89
+ if (s && typeof s === 'string' && s.length > 50) {
90
+ if (s.includes('<a ') && s.includes('http')) { foundHtml = s; return s }
91
+ runInSandbox(s, depth + 1)
92
+ }
93
+ return s
94
+ },
95
+ console: { log: () => {}, error: () => {} },
96
+ setTimeout: () => 0, clearTimeout: () => {},
97
+ }
98
+ try { vm.runInNewContext(code, sandbox, { timeout: 8000 }) } catch (_) {}
99
+ }
100
+
101
+ runInSandbox(rawData)
102
+ if (!foundHtml)
103
+ foundHtml = Object.values(capturedHtmls).find(h => h.includes('<a ') && h.includes('http')) || null
104
+
105
+ return foundHtml
106
+ ? foundHtml.replace(/\\"/g, '"').replace(/\\'/g, "'").replace(/\\\\/g, '\\').replace(/\\n/g, '\n')
107
+ : null
108
+ }
109
+
110
+ // ── TikTok Metadata ────────────────────────────────────────────────────────
111
+
112
+ /*
113
+ * Ambil metadata TikTok (title, author, stats) dari oEmbed + embed page
114
+ * @param {string} videoUrl - URL TikTok video
115
+ * @param {string} videoId - ID video (opsional, untuk scrape stats)
116
+ */
117
+ async function getTiktokMeta(videoUrl, videoId = null) {
118
+ let title = '', author = '', authorUrl = '', thumbnail = ''
119
+ let likes = null, views = null, shares = null, comments = null
120
+
121
+ // 1. oEmbed — dapat title, author, thumbnail
122
+ try {
123
+ const oe = await axios.get(
124
+ `https://www.tiktok.com/oembed?url=${encodeURIComponent(videoUrl)}`,
125
+ { headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: 10000 }
126
+ )
127
+ title = oe.data.title || ''
128
+ author = oe.data.author_name || ''
129
+ authorUrl = oe.data.author_url || ''
130
+ thumbnail = oe.data.thumbnail_url || ''
131
+ } catch (_) {}
132
+
133
+ // 2. Embed page — dapat stats (likes, views, shares, comments)
134
+ if (videoId) {
135
+ try {
136
+ const embedRes = await axios.get(
137
+ `https://www.tiktok.com/embed/v2/${videoId}`,
138
+ {
139
+ headers: {
140
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/124.0 Mobile Safari/537.36',
141
+ 'Referer': 'https://www.tiktok.com/',
142
+ },
143
+ timeout: 15000,
144
+ }
145
+ )
146
+ const html = embedRes.data
147
+
148
+ // Stats ada di JSON __DEFAULT_SCOPE__ atau NEXT_DATA
149
+ const jsonMatch = html.match(/"stats"\s*:\s*(\{[^}]+\})/)
150
+ || html.match(/diggCount[^}]{0,200}/)
151
+
152
+ if (jsonMatch) {
153
+ const statsStr = jsonMatch[0]
154
+ const dig = statsStr.match(/diggCount['":\s]+(\d+)/)
155
+ const play = statsStr.match(/playCount['":\s]+(\d+)/)
156
+ const cmt = statsStr.match(/commentCount['":\s]+(\d+)/)
157
+ const shr = statsStr.match(/shareCount['":\s]+(\d+)/)
158
+ likes = dig ? parseInt(dig[1]) : null
159
+ views = play ? parseInt(play[1]) : null
160
+ comments = cmt ? parseInt(cmt[1]) : null
161
+ shares = shr ? parseInt(shr[1]) : null
162
+ }
163
+
164
+ // Fallback: cari di window.__NEXT_DATA__ atau __DEFAULT_SCOPE__
165
+ if (!likes) {
166
+ const nextData = html.match(/<script[^>]*id="__NEXT_DATA__"[^>]*>([^<]+)<\/script>/)
167
+ if (nextData) {
168
+ try {
169
+ const obj = JSON.parse(nextData[1])
170
+ const stats = obj?.props?.pageProps?.itemInfo?.itemStruct?.stats
171
+ || obj?.props?.pageProps?.videoData?.stats
172
+ if (stats) {
173
+ likes = stats.diggCount || null
174
+ views = stats.playCount || null
175
+ comments = stats.commentCount || null
176
+ shares = stats.shareCount || null
177
+ }
178
+ } catch (_) {}
179
+ }
180
+ }
181
+ } catch (_) {}
182
+ }
183
+
184
+ return { title, author, authorUrl, thumbnail, likes, views, comments, shares }
185
+ }
186
+
187
+ // ── TikTok Video Downloader ────────────────────────────────────────────────
188
+
189
+ /*
190
+ * Download video TikTok dari snaptik + metadata dari TikTok embed
191
+ * @param {string} url - URL TikTok video
192
+ */
193
+ const tiktokDL = async (url) => {
194
+ return new Promise(async (resolve) => {
195
+ try {
196
+ if (!url || !url.includes('tiktok.com'))
197
+ return resolve(fail('URL TikTok tidak valid'))
198
+
199
+ // Resolve URL pendek → dapat video ID
200
+ const { url: fullUrl, id: videoId } = await resolveUrl(url)
201
+
202
+ // Fetch snaptik
203
+ const rawData = await snaptikFetch(fullUrl)
204
+ const decoded = decodeSnaptik(rawData)
205
+ if (!decoded) return resolve(fail('Gagal decode respons snaptik'))
206
+
207
+ // Parse download links
208
+ const $ = cheerio.load(decoded)
209
+ let video = null, video_hd = null, music = null
210
+
211
+ const errEl = $('.error, .alert-danger').first().text().trim()
212
+ if (errEl) return resolve(fail(errEl))
213
+
214
+ $('a[href]').each((_, el) => {
215
+ let href = $(el).attr('href') || ''
216
+ if (href.startsWith('//')) href = 'https:' + href
217
+ if (!href.startsWith('http')) return
218
+
219
+ const txt = $(el).text().toLowerCase().trim()
220
+ const cls = $(el).attr('class') || ''
221
+ const dl = $(el).attr('download') || ''
222
+
223
+ const isHD = txt.includes('hd') || cls.includes('hd') || dl.includes('hd')
224
+ const isVideo = /\.mp4(\?|$)/i.test(href) || txt.includes('video') || dl.includes('mp4')
225
+ const isMusic = /\.(mp3|m4a)(\?|$)/i.test(href) || txt.includes('music') || txt.includes('audio') || dl.includes('mp3')
226
+
227
+ if (isMusic && !music) music = href
228
+ else if (isHD && !video_hd) video_hd = href
229
+ else if (isVideo && !video) video = href
230
+ })
231
+
232
+ // Ambil metadata TikTok
233
+ const meta = await getTiktokMeta(fullUrl, videoId)
234
+
235
+ if (!video && !video_hd) return resolve(fail('Tidak ditemukan link download — URL tidak valid atau private'))
236
+
237
+ resolve(ok({
238
+ id: videoId,
239
+ title: meta.title,
240
+ author: meta.author,
241
+ authorUrl: meta.authorUrl,
242
+ thumbnail: meta.thumbnail,
243
+ stats: {
244
+ likes: meta.likes,
245
+ views: meta.views,
246
+ comments: meta.comments,
247
+ shares: meta.shares,
248
+ },
249
+ video,
250
+ video_hd,
251
+ music,
252
+ }))
253
+
254
+ } catch (e) { console.log('[tiktokVideo]', e.message); resolve(fail(e)) }
255
+ })
256
+ }
257
+
258
+ // ── TikTok Slide Downloader ────────────────────────────────────────────────
259
+
260
+ /*
261
+ * Download TikTok slide — gambar + audio via ssstik.io
262
+ * @param {string} url - URL TikTok video/slide
263
+ */
264
+ const tiktokSlide = async (url) => {
265
+ return new Promise(async (resolve) => {
266
+ try {
267
+ if (!url || !url.includes('tiktok.com'))
268
+ return resolve(fail('URL TikTok tidak valid'))
269
+
270
+ const H = {
271
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.82 Mobile Safari/537.36',
272
+ 'Accept-Language': 'id-ID,id;q=0.9',
273
+ }
274
+
275
+ // Step 1: Ambil tt token dari halaman ssstik
276
+ const pageRes = await axios.get('https://ssstik.io/id', { headers: H, timeout: 15000 })
277
+ const $page = cheerio.load(pageRes.data)
278
+ const tt = $page('input[name="tt"]').val() || ''
279
+
280
+ // Step 2: Submit URL ke ssstik
281
+ const formRes = await axios.post(
282
+ 'https://ssstik.io/abc?url=dl',
283
+ new URLSearchParams({ id: url, locale: 'id', tt }).toString(),
284
+ {
285
+ headers: {
286
+ ...H,
287
+ 'Content-Type': 'application/x-www-form-urlencoded',
288
+ 'Referer': 'https://ssstik.io/id',
289
+ 'Origin': 'https://ssstik.io',
290
+ 'HX-Request': 'true',
291
+ 'HX-Target': 'target',
292
+ },
293
+ timeout: 30000,
294
+ }
295
+ )
296
+
297
+ const $r = cheerio.load(formRes.data)
298
+ const slides = [], videos = []
299
+ let music = null
300
+
301
+ // Cek error
302
+ const err = $r('.error, .alert, [class*="error"]').first().text().trim()
303
+ if (err && err.length < 200) return resolve(fail(err))
304
+
305
+ $r('a[href]').each((_, el) => {
306
+ const href = $r(el).attr('href') || ''
307
+ const txt = $r(el).text().toLowerCase().trim()
308
+ const cls = $r(el).attr('class') || ''
309
+ if (!href.startsWith('http')) return
310
+
311
+ const isMusic = txt.includes('mp3') || txt.includes('musik') || txt.includes('music') || txt.includes('audio') || href.includes('/ssstik/m/')
312
+ const isVideo = /\.mp4/i.test(href) || txt.includes('video') || txt.includes('mp4')
313
+ const isSlide = href.includes('/ssstik/') && !isMusic && !isVideo
314
+
315
+ if (isMusic && !music) music = href
316
+ else if (isSlide && !slides.includes(href)) slides.push(href)
317
+ else if (isVideo && !videos.includes(href)) videos.push(href)
318
+ })
319
+
320
+ if (!videos.length && !slides.length && !music)
321
+ return resolve(fail('Tidak ditemukan link download — URL tidak valid atau private'))
322
+
323
+ resolve(ok({
324
+ type: slides.length > 0 ? 'slide' : 'video',
325
+ video: videos[0] || null,
326
+ video_hd: videos[1] || null,
327
+ slides,
328
+ slides_count: slides.length,
329
+ music,
330
+ }))
331
+
332
+ } catch (e) { console.log('[tiktokSlide]', e.message); resolve(fail(e)) }
333
+ })
334
+ }
335
+
336
+ // Decode render_token JWT
337
+ const getRenderVideo = (renderToken, fallbackAudio = null) => {
338
+ try {
339
+ const payload = JSON.parse(Buffer.from(renderToken.split('.')[1], 'base64').toString())
340
+ const { image_urls, audio_url, filename, id } = payload
341
+ if (!image_urls?.length) return fail('render_token tidak mengandung image_urls')
342
+ return ok({ image_urls, audio_url: audio_url || fallbackAudio || null, filename: filename || ('SnapTik_' + (id || Date.now()) + '.mp4'), id: id || null })
343
+ } catch (e) { return fail('Gagal decode render_token: ' + e.message) }
344
+ }
345
+
346
+ /*
347
+ * Render slide menjadi video MP4 menggunakan ffmpeg
348
+ * Butuh: ffmpeg terinstall (pkg install ffmpeg)
349
+ * @param {object} param - { image_urls, audio_url, filename }
350
+ */
351
+ const renderToVideo = async ({ image_urls, audio_url, filename }) => {
352
+ const fs = require('fs')
353
+ const path = require('path')
354
+ const { execSync } = require('child_process')
355
+
356
+ // Buat folder temp jika belum ada
357
+ const tempBase = path.join('.', 'temp')
358
+ if (!fs.existsSync(tempBase)) fs.mkdirSync(tempBase, { recursive: true })
359
+
360
+ const tmpDir = fs.mkdtempSync(path.join(tempBase, 'snaptik-'))
361
+ const dirName = path.basename(tmpDir) // snaptik-Zg40tu
362
+ const outName = filename || `${dirName}.mp4` // fallback: snaptik-Zg40tu.mp4
363
+ const outFile = path.join(tmpDir, outName)
364
+
365
+ try {
366
+ const imgHeaders = {
367
+ 'User-Agent': 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.82 Mobile Safari/537.36',
368
+ 'Referer': 'https://ssstik.io/',
369
+ 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
370
+ }
371
+
372
+ // Download semua foto
373
+ const imgPaths = []
374
+ for (let i = 0; i < image_urls.length; i++) {
375
+ const imgPath = path.join(tmpDir, `img_${i}.jpg`)
376
+ const res = await axios.get(image_urls[i], { headers: imgHeaders, responseType: 'arraybuffer', timeout: 30000 })
377
+ fs.writeFileSync(imgPath, res.data)
378
+ imgPaths.push(imgPath)
379
+ }
380
+
381
+ // Download audio — validasi magic bytes
382
+ let audioPath = null
383
+ if (audio_url) {
384
+ const audioAttempts = [
385
+ { 'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/124.0', 'Referer': 'https://ssstik.io/' },
386
+ { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', 'Referer': 'https://www.tiktok.com/' },
387
+ { 'User-Agent': 'Mozilla/5.0' },
388
+ ]
389
+ for (const h of audioAttempts) {
390
+ try {
391
+ const audioRes = await axios.get(audio_url, { headers: h, responseType: 'arraybuffer', timeout: 30000 })
392
+ const buf = Buffer.from(audioRes.data)
393
+ if (buf.length < 1000) continue
394
+ const magic = buf.slice(0, 4).toString('hex')
395
+ const valid = magic.startsWith('fff') || magic.startsWith('4944') || buf.slice(4,8).toString() === 'ftyp' || magic === '4f676753'
396
+ if (valid) { audioPath = path.join(tmpDir, 'audio.mp3'); fs.writeFileSync(audioPath, buf); break }
397
+ } catch (_) {}
398
+ }
399
+ }
400
+
401
+ const scale = 'scale=trunc(iw/2)*2:trunc(ih/2)*2'
402
+ let ffmpegCmd
403
+
404
+ if (imgPaths.length === 1) {
405
+ // Single image
406
+ ffmpegCmd = audioPath
407
+ ? `ffmpeg -y -loop 1 -i "${imgPaths[0]}" -i "${audioPath}" -c:v libx264 -tune stillimage -c:a aac -b:a 192k -shortest -pix_fmt yuv420p -vf "${scale}" "${outFile}"`
408
+ : `ffmpeg -y -loop 1 -i "${imgPaths[0]}" -f lavfi -i anullsrc=r=44100:cl=stereo -c:v libx264 -tune stillimage -c:a aac -b:a 64k -t 30 -pix_fmt yuv420p -vf "${scale}" "${outFile}"`
409
+
410
+ } else {
411
+ // Multiple images — setiap foto tampil perImg detik
412
+ let audioDur = 30
413
+ if (audioPath) {
414
+ try {
415
+ const probe = execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${audioPath}"`, { timeout: 10000 }).toString().trim()
416
+ audioDur = parseFloat(probe) || 30
417
+ } catch (_) {}
418
+ }
419
+ const perImg = Math.max(1, audioDur / imgPaths.length)
420
+
421
+ // Step 1: Buat slide video (gambar bergantian, tanpa audio)
422
+ // Pakai -loop 1 -t per gambar + concat filter (bukan concat demuxer)
423
+ const slideVideo = path.join(tmpDir, 'slides.mp4')
424
+ const inputs = imgPaths.map(p => `-loop 1 -t ${perImg.toFixed(3)} -i "${p}"`).join(' ')
425
+ // Setelah download semua foto, ambil dimensi referensi dari img pertama
426
+ let refW = 0, refH = 0
427
+ try {
428
+ const probeOut = execSync(
429
+ `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "${imgPaths[0]}"`,
430
+ { timeout: 10000 }
431
+ ).toString().trim()
432
+ const [w, h] = probeOut.split(',').map(Number)
433
+ refW = w % 2 === 0 ? w : w - 1
434
+ refH = h % 2 === 0 ? h : h - 1
435
+ } catch (_) {}
436
+
437
+ const filterParts = imgPaths.map((_, i) =>
438
+ refW && refH
439
+ ? `[${i}:v]scale=${refW}:${refH}:force_original_aspect_ratio=decrease,pad=${refW}:${refH}:(ow-iw)/2:(oh-ih)/2,setsar=1[v${i}]`
440
+ : `[${i}:v]scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1[v${i}]`
441
+ ).join(';')
442
+ const concatIn = imgPaths.map((_, i) => `[v${i}]`).join('')
443
+ const filter = `${filterParts};${concatIn}concat=n=${imgPaths.length}:v=1:a=0[vout]`
444
+
445
+ const slideCmd = `ffmpeg -y ${inputs} -filter_complex "${filter}" -map "[vout]" -c:v libx264 -pix_fmt yuv420p -r 25 "${slideVideo}"`
446
+ execSync(slideCmd, { timeout: 120000, stdio: 'pipe' })
447
+
448
+ // Step 2: Gabungkan slide video + audio
449
+ ffmpegCmd = audioPath
450
+ ? `ffmpeg -y -i "${slideVideo}" -i "${audioPath}" -c:v copy -c:a aac -b:a 192k -shortest "${outFile}"`
451
+ : `ffmpeg -y -i "${slideVideo}" -f lavfi -i anullsrc=r=44100:cl=stereo -c:v copy -c:a aac -b:a 64k -shortest "${outFile}"`
452
+ }
453
+
454
+ execSync(ffmpegCmd, { timeout: 120000, stdio: 'pipe' })
455
+ const videoBuffer = fs.readFileSync(outFile)
456
+ const sizeMB = (videoBuffer.length / 1024 / 1024).toFixed(2)
457
+ return ok({
458
+ buffer: videoBuffer,
459
+ path: outFile,
460
+ tmpDir,
461
+ filename: outName,
462
+ size: videoBuffer.length,
463
+ size_mb: parseFloat(sizeMB),
464
+ has_audio: !!audioPath,
465
+ })
466
+ } catch (e) {
467
+ try { require('fs').rmSync(tmpDir, { recursive: true }) } catch (_) {}
468
+ return fail('renderToVideo error: ' + e.message)
469
+ }
470
+ }
471
+
472
+ module.exports = { tiktokDL, tiktokSlide, getRenderVideo, renderToVideo }
@@ -0,0 +1,51 @@
1
+ const { fetchHTML, cheerio, ok, fail } = require('../utils')
2
+
3
+ /* Harga BBM Pertamina — sumber: oto.com/en/harga-bbm */
4
+ const bbm = async () => {
5
+ return new Promise(async (resolve) => {
6
+ try {
7
+ const html = await fetchHTML('https://www.oto.com/en/harga-bbm', {
8
+ Referer: 'https://www.oto.com/'
9
+ })
10
+ const $ = cheerio.load(html)
11
+
12
+ // Tanggal update
13
+ const updateText = $('p,span,div').filter((_,el) =>
14
+ /last updated|update/i.test($(el).text())
15
+ ).first().text().trim().replace(/\s+/g, ' ')
16
+
17
+ // Tabel 1 & 2 — harga per jenis BBM (bensin & diesel)
18
+ const harga = []
19
+ ;[1, 2].forEach(idx => {
20
+ $('table').eq(idx).find('tbody tr').each((_, el) => {
21
+ const cols = $(el).find('td')
22
+ const jenis = $(cols[0]).text().trim()
23
+ const hargaLiter = $(cols[1]).text().trim().replace(/\s+/g, ' ')
24
+ if (jenis && hargaLiter && /Rp/i.test(hargaLiter)) {
25
+ harga.push({ jenis, harga: hargaLiter })
26
+ }
27
+ })
28
+ })
29
+
30
+ // Tabel 3 — harga per kota/provinsi
31
+ const headers = $('table').eq(3).find('th').map((_,el) => $(el).text().trim()).get()
32
+ const provinsi = []
33
+ $('table').eq(3).find('tbody tr').each((_, el) => {
34
+ const cols = $(el).find('td')
35
+ if (!cols.length) return
36
+ const row = {}
37
+ cols.each((i, td) => {
38
+ if (headers[i]) row[headers[i]] = $(td).text().trim()
39
+ })
40
+ if (row[headers[0]]) provinsi.push(row)
41
+ })
42
+
43
+ if (!harga.length && !provinsi.length)
44
+ return resolve(fail('Data BBM tidak ditemukan'))
45
+
46
+ resolve(ok({ update: updateText, harga, provinsi }))
47
+ } catch (e) { console.log(e); resolve(fail(e)) }
48
+ })
49
+ }
50
+
51
+ module.exports = { bbm }
@@ -0,0 +1,46 @@
1
+ const { fetchHTML, cheerio, ok, fail } = require('../utils')
2
+
3
+ /* Harga emas Antam dari logammulia.com */
4
+ const emasAntam = async () => {
5
+ return new Promise(async (resolve) => {
6
+ try {
7
+ const html = await fetchHTML('https://www.logammulia.com/id/harga-emas-hari-ini')
8
+ const $ = cheerio.load(html)
9
+ const data = []
10
+ const tanggal = $("h2.ngc-title:contains('Harga Emas')").first().text().trim()
11
+ $('table').first().find('tbody tr').each((_, el) => {
12
+ const cols = $(el).find('td')
13
+ if (cols.length < 3) return
14
+ const berat = $(cols[0]).text().trim()
15
+ const harga_dasar = $(cols[1]).text().trim()
16
+ const harga_pajak = $(cols[2]).text().trim()
17
+ if (berat && harga_dasar) data.push({ berat, harga_dasar, harga_termasuk_pajak: harga_pajak })
18
+ })
19
+ if (!data.length) return resolve(fail('Data emas tidak ditemukan'))
20
+ resolve(ok({ tanggal, data }))
21
+ } catch (e) { console.log(e); resolve(fail(e)) }
22
+ })
23
+ }
24
+
25
+ /* Harga emas dunia dari harga-emas.org */
26
+ const emasHarga = async () => {
27
+ return new Promise(async (resolve) => {
28
+ try {
29
+ const html = await fetchHTML('https://harga-emas.org/')
30
+ const $ = cheerio.load(html)
31
+ const data = []
32
+ const tanggal = $('div.UbsGoldTable_updateNote__4gXTx').first().text().trim()
33
+ $('table tbody tr').each((_, el) => {
34
+ const cols = $(el).find('td')
35
+ if (cols.length < 2) return
36
+ const jenis = $(cols[0]).text().trim()
37
+ const harga = $(cols[1]).text().trim()
38
+ if (jenis && harga) data.push({ jenis, harga })
39
+ })
40
+ if (!data.length) return resolve(fail('Data emas tidak ditemukan'))
41
+ resolve(ok({ tanggal, data }))
42
+ } catch (e) { console.log(e); resolve(fail(e)) }
43
+ })
44
+ }
45
+
46
+ module.exports = { emasAntam, emasHarga }
@@ -0,0 +1,64 @@
1
+ const { fetchHTML, cheerio, ok, fail } = require('../utils')
2
+
3
+ /*
4
+ * Kurs satu mata uang ke IDR dari Kemenkeu
5
+ * @param {string} kode - usd | eur | gbp | jpy | sgd | myr | aud | cny | sar | dll
6
+ */
7
+ const kurs = async (kode = 'usd') => {
8
+ return new Promise(async (resolve) => {
9
+ try {
10
+ const html = await fetchHTML('https://fiskal.kemenkeu.go.id/informasi-publik/kurs-pajak')
11
+ const $ = cheerio.load(html)
12
+ const target = kode.toUpperCase()
13
+ let result = null
14
+
15
+ $('table tbody tr').each((_, el) => {
16
+ const cols = $(el).find('td')
17
+ if (cols.length < 3) return
18
+ const fullText = $(cols[1]).text().trim()
19
+ const kodeMatch = fullText.match(/\b([A-Z]{3})\b/)
20
+ const kodeM = kodeMatch ? kodeMatch[1] : ''
21
+ if (kodeM !== target) return
22
+ const nama = fullText.split('\n')[0].trim()
23
+ result = {
24
+ mata_uang: nama,
25
+ kode: kodeM,
26
+ nilai: $(cols[2]).text().trim(),
27
+ perubahan: $(cols[3]).text().trim() || null,
28
+ sumber: 'Kemenkeu',
29
+ }
30
+ })
31
+
32
+ if (!result) return resolve(fail(`Kurs ${target} tidak ditemukan`))
33
+ resolve(ok(result))
34
+ } catch (e) { console.log(e); resolve(fail(e)) }
35
+ })
36
+ }
37
+
38
+ /* Semua kurs dari Kemenkeu */
39
+ const kursAll = async () => {
40
+ return new Promise(async (resolve) => {
41
+ try {
42
+ const html = await fetchHTML('https://fiskal.kemenkeu.go.id/informasi-publik/kurs-pajak')
43
+ const $ = cheerio.load(html)
44
+ const data = []
45
+
46
+ $('table tbody tr').each((_, el) => {
47
+ const cols = $(el).find('td')
48
+ if (cols.length < 3) return
49
+ const fullText = $(cols[1]).text().trim()
50
+ const kodeMatch = fullText.match(/\b([A-Z]{3})\b/)
51
+ const kode = kodeMatch ? kodeMatch[1] : ''
52
+ const nama = fullText.split('\n')[0].trim()
53
+ const nilai = $(cols[2]).text().trim()
54
+ const perubahan = $(cols[3]).text().trim() || null
55
+ if (nama && kode) data.push({ mata_uang: nama, kode, nilai, perubahan })
56
+ })
57
+
58
+ if (!data.length) return resolve(fail('Data kurs tidak ditemukan'))
59
+ resolve(ok({ sumber: 'Kemenkeu', data }))
60
+ } catch (e) { console.log(e); resolve(fail(e)) }
61
+ })
62
+ }
63
+
64
+ module.exports = { kurs, kursAll }