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.
- package/README.md +646 -0
- package/index.js +38 -0
- package/indo-scraper.zip +0 -0
- package/package.json +26 -0
- package/src/bmkg/cuaca.js +34 -0
- package/src/bmkg/gempa.js +56 -0
- package/src/downloader/facebook.js +94 -0
- package/src/downloader/gdrive.js +38 -0
- package/src/downloader/instagram.js +62 -0
- package/src/downloader/mediafire.js +30 -0
- package/src/downloader/spotify.js +262 -0
- package/src/downloader/tiktok.js +472 -0
- package/src/finance/bbm.js +51 -0
- package/src/finance/emas.js +46 -0
- package/src/finance/kurs.js +64 -0
- package/src/finance/saham.js +117 -0
- package/src/info/cekno.js +39 -0
- package/src/info/resi.js +82 -0
- package/src/news/antara.js +66 -0
- package/src/news/cnn.js +71 -0
- package/src/news/detik.js +108 -0
- package/src/news/kompas.js +70 -0
- package/src/news/liputan6.js +65 -0
- package/src/news/okezone.js +72 -0
- package/src/news/republika.js +73 -0
- package/src/news/tribun.js +95 -0
- package/src/tools/simsimi.js +69 -0
- package/src/tools/ssweb.js +35 -0
- package/src/utils.js +79 -0
|
@@ -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 }
|