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,34 @@
1
+ const { fetchJSON, ok, fail } = require('../utils')
2
+
3
+ /*
4
+ * Prakiraan cuaca kota populer
5
+ * @param {string} kota - jakarta | bandung | surabaya | medan | semarang | makassar | yogyakarta | palembang | denpasar | balikpapan
6
+ */
7
+ const bmkgCuaca = async (kota = 'jakarta') => {
8
+ return new Promise(async (resolve) => {
9
+ try {
10
+ const KOTA = {
11
+ jakarta: '31.71.05.1001', bandung: '32.73.04.1002', surabaya: '35.78.27.1001',
12
+ medan: '12.71.01.1001', semarang: '33.74.01.1001', makassar: '73.71.01.1001',
13
+ yogyakarta: '34.71.01.1001', palembang: '16.71.01.1001',
14
+ denpasar: '51.71.01.1001', balikpapan: '64.72.01.1001',
15
+ }
16
+ const adm4 = KOTA[kota.toLowerCase()]
17
+ if (!adm4) return resolve(fail(`Kota tidak tersedia. Pilihan: ${Object.keys(KOTA).join(', ')}`))
18
+ const json = await fetchJSON(`https://api.bmkg.go.id/publik/prakiraan-cuaca?adm4=${adm4}`)
19
+ const lokasi = json.data?.[0]?.lokasi || {}
20
+ const cuaca = (json.data?.[0]?.cuaca || []).flat().map(i => ({
21
+ datetime: i.local_datetime, cuaca: i.weather_desc,
22
+ suhu: i.t, suhu_min: i.tmin, suhu_max: i.tmax,
23
+ kelembaban: i.hu, angin: i.ws, arah_angin: i.wd, icon: i.image,
24
+ }))
25
+ if (!cuaca.length) return resolve(fail('Data cuaca tidak ditemukan'))
26
+ resolve(ok({
27
+ lokasi: { kecamatan: lokasi.kecamatan, kotkab: lokasi.kotkab, provinsi: lokasi.provinsi },
28
+ prakiraan: cuaca,
29
+ }))
30
+ } catch (e) { console.log(e); resolve(fail(e)) }
31
+ })
32
+ }
33
+
34
+ module.exports = { bmkgCuaca }
@@ -0,0 +1,56 @@
1
+ const { fetchJSON, ok, fail } = require('../utils')
2
+
3
+ /* Gempa terbaru (1 data) */
4
+ const bmkgGempa = async () => {
5
+ return new Promise(async (resolve) => {
6
+ try {
7
+ const json = await fetchJSON('https://data.bmkg.go.id/DataMKG/TEWS/autogempa.json')
8
+ const g = json.Infogempa.gempa
9
+ resolve(ok({
10
+ tanggal: g.Tanggal, jam: g.Jam, datetime: g.DateTime,
11
+ koordinat: g.Coordinates, lintang: g.Lintang, bujur: g.Bujur,
12
+ magnitude: g.Magnitude, kedalaman: g.Kedalaman,
13
+ wilayah: g.Wilayah, potensi: g.Potensi, dirasakan: g.Dirasakan,
14
+ shakemap: `https://data.bmkg.go.id/DataMKG/TEWS/${g.Shakemap}`,
15
+ }))
16
+ } catch (e) { console.log(e); resolve(fail(e)) }
17
+ })
18
+ }
19
+
20
+ /* 15 gempa terkini */
21
+ const bmkgGempaTerkini = async () => {
22
+ return new Promise(async (resolve) => {
23
+ try {
24
+ const json = await fetchJSON('https://data.bmkg.go.id/DataMKG/TEWS/gempaterkini.json')
25
+ const list = json.Infogempa.gempa
26
+ if (!list?.length) return resolve(fail('Data tidak ditemukan'))
27
+ resolve(ok(list.map(g => ({
28
+ tanggal: g.Tanggal, jam: g.Jam, datetime: g.DateTime,
29
+ koordinat: g.Coordinates, lintang: g.Lintang, bujur: g.Bujur,
30
+ magnitude: g.Magnitude, kedalaman: g.Kedalaman,
31
+ wilayah: g.Wilayah, potensi: g.Potensi,
32
+ shakemap: `https://data.bmkg.go.id/DataMKG/TEWS/${g.Shakemap}`,
33
+ }))))
34
+ } catch (e) { console.log(e); resolve(fail(e)) }
35
+ })
36
+ }
37
+
38
+ /* Gempa yang dirasakan */
39
+ const bmkgGempaDirasakan = async () => {
40
+ return new Promise(async (resolve) => {
41
+ try {
42
+ const json = await fetchJSON('https://data.bmkg.go.id/DataMKG/TEWS/gempadirasakan.json')
43
+ const list = json.Infogempa.gempa
44
+ if (!list?.length) return resolve(fail('Data tidak ditemukan'))
45
+ resolve(ok(list.map(g => ({
46
+ tanggal: g.Tanggal, jam: g.Jam, datetime: g.DateTime,
47
+ koordinat: g.Coordinates, lintang: g.Lintang, bujur: g.Bujur,
48
+ magnitude: g.Magnitude, kedalaman: g.Kedalaman,
49
+ wilayah: g.Wilayah, dirasakan: g.Dirasakan, potensi: g.Potensi,
50
+ shakemap: `https://data.bmkg.go.id/DataMKG/TEWS/${g.Shakemap}`,
51
+ }))))
52
+ } catch (e) { console.log(e); resolve(fail(e)) }
53
+ })
54
+ }
55
+
56
+ module.exports = { bmkgGempa, bmkgGempaTerkini, bmkgGempaDirasakan }
@@ -0,0 +1,94 @@
1
+ const { axios, cheerio, ok, fail } = require('../utils')
2
+
3
+ const _getTitle = async (url) => {
4
+ try {
5
+ const res = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 13)', 'Accept-Language': 'id-ID,id;q=0.9' }, timeout: 15000 })
6
+ const $ = cheerio.load(res.data)
7
+ return ($('meta[property="og:description"]').attr('content') ||
8
+ $('meta[name="description"]').attr('content') ||
9
+ $('meta[property="og:title"]').attr('content') ||
10
+ $('title').text() || '')
11
+ .replace(/\| Facebook.*$/i, '').replace(/&/g, '&').replace(/\s+/g, ' ').trim()
12
+ } catch (_) { return '' }
13
+ }
14
+
15
+ /*
16
+ * Facebook Video Downloader
17
+ * Source: fdown.net
18
+ * @param {string} url
19
+ */
20
+ const facebook = async (url) => {
21
+ return new Promise(async (resolve) => {
22
+ try {
23
+ if (!url) return resolve(fail('URL tidak boleh kosong'))
24
+ if (!url.includes('facebook.com') && !url.includes('fb.watch'))
25
+ return resolve(fail('URL Facebook tidak valid'))
26
+
27
+ const title = await _getTitle(url) || 'Facebook Video'
28
+
29
+ const res = await axios.post(
30
+ 'https://fdown.net/download.php',
31
+ new URLSearchParams({ URLz: url }).toString(),
32
+ { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'Mozilla/5.0 (Linux; Android 13)', 'Origin': 'https://fdown.net', 'Referer': 'https://fdown.net/' }, timeout: 20000 }
33
+ )
34
+
35
+ const $ = cheerio.load(res.data)
36
+ const thumbnail = $('.lib-item img').attr('src') || $('img').first().attr('src') || null
37
+ let sd = null, hd = null
38
+
39
+ $('a').each((_, el) => {
40
+ const href = $(el).attr('href')
41
+ const text = $(el).text().trim()
42
+ if (!href) return
43
+ if (text.includes('Download Video in HD Quality')) hd = href
44
+ if (text.includes('Download Video in Normal Quality')) sd = href
45
+ })
46
+
47
+ if (!sd) sd = res.data.match(/Download Video in Normal Quality.*?href="(.*?)"/s)?.[1] || null
48
+ if (!hd) hd = res.data.match(/Download Video in HD Quality.*?href="(.*?)"/s)?.[1] || null
49
+ if (!sd && !hd) return resolve(fail('Video tidak ditemukan atau private'))
50
+
51
+ resolve(ok({ title, thumbnail, sd, hd: hd || sd }))
52
+ } catch (e) { console.log(e); resolve(fail(e?.response?.status || e.message)) }
53
+ })
54
+ }
55
+
56
+ /*
57
+ * Facebook Photo Downloader
58
+ * Support: post photo, album, carousel, share link
59
+ * @param {string} url
60
+ */
61
+ const facebookPhoto = async (url) => {
62
+ return new Promise(async (resolve) => {
63
+ try {
64
+ if (!url) return resolve(fail('URL tidak boleh kosong'))
65
+ if (!url.includes('facebook.com') && !url.includes('fb.watch'))
66
+ return resolve(fail('URL Facebook tidak valid'))
67
+
68
+ const res = await axios.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Linux; Android 13)', 'Accept-Language': 'id-ID,id;q=0.9' }, timeout: 20000 })
69
+ const $ = cheerio.load(res.data)
70
+
71
+ const title = ($('meta[property="og:description"]').attr('content') ||
72
+ $('meta[name="description"]').attr('content') ||
73
+ $('meta[property="og:title"]').attr('content') || 'Facebook Photo')
74
+ .replace(/\| Facebook.*$/i, '').replace(/&/g, '&').replace(/\s+/g, ' ').trim()
75
+
76
+ const medias = []
77
+ const og = $('meta[property="og:image"]').attr('content')
78
+ if (og) medias.push(og)
79
+
80
+ const matches = res.data.match(/https:\/\/scontent.*?\.(jpg|png|jpeg).*?(?=")/g) || []
81
+ for (let img of matches) {
82
+ img = img.replace(/\\\//g, '/').replace(/\\u0025/g, '%')
83
+ medias.push(img)
84
+ }
85
+
86
+ const clean = [...new Set(medias)]
87
+ if (!clean.length) return resolve(fail('Foto tidak ditemukan'))
88
+
89
+ resolve(ok({ title, total: clean.length, medias: clean.map(v => ({ type: 'image', url: v })) }))
90
+ } catch (e) { console.log(e); resolve(fail(e?.response?.status || e.message)) }
91
+ })
92
+ }
93
+
94
+ module.exports = { facebook, facebookPhoto }
@@ -0,0 +1,38 @@
1
+ const { axios, ok, fail } = require('../utils')
2
+
3
+ const _formatBytes = (bytes) => {
4
+ if (!bytes) return null
5
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
6
+ const i = Math.floor(Math.log(bytes) / Math.log(1024))
7
+ return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]
8
+ }
9
+
10
+ /*
11
+ * Google Drive Downloader
12
+ * @param {string} url
13
+ */
14
+ const gdrive = async (url) => {
15
+ return new Promise(async (resolve) => {
16
+ try {
17
+ if (!url || !url.includes('drive.google.com'))
18
+ return resolve(fail('URL Google Drive tidak valid'))
19
+
20
+ const id = url.match(/\/d\/(.*?)\//)?.[1] || url.match(/[?&]id=([^&]+)/)?.[1] || null
21
+ if (!id) return resolve(fail('File ID tidak ditemukan'))
22
+
23
+ const preview = `https://drive.google.com/file/d/${id}/view`
24
+ const direct = `https://drive.google.com/uc?export=download&id=${id}`
25
+
26
+ const page = await axios.get(preview, { headers: { 'User-Agent': 'Mozilla/5.0' }, timeout: 15000 })
27
+ const filename = page.data.match(/<title>(.*?) - Google Drive<\/title>/)?.[1] || 'unknown'
28
+
29
+ const head = await axios({ url: direct, method: 'GET', maxRedirects: 5, responseType: 'stream', headers: { 'User-Agent': 'Mozilla/5.0' } })
30
+ const filesize = _formatBytes(Number(head.headers['content-length']) || 0)
31
+ const mimetype = head.headers['content-type'] || 'unknown'
32
+
33
+ resolve(ok({ id, filename, filesize, mimetype, direct, preview }))
34
+ } catch (e) { console.log(e); resolve(fail(e)) }
35
+ })
36
+ }
37
+
38
+ module.exports = { gdrive }
@@ -0,0 +1,62 @@
1
+ const { cheerio, ok, fail } = require('../utils')
2
+ const vm = require('vm')
3
+
4
+ /*
5
+ * Instagram Downloader — foto, video, reels, carousel
6
+ * Sumber: snapsave.app
7
+ * @param {string} url - URL post/reel Instagram
8
+ */
9
+ const instagram = async (url) => {
10
+ return new Promise(async (resolve) => {
11
+ try {
12
+ if (!url || !url.includes('instagram.com'))
13
+ return resolve(fail('URL Instagram tidak valid'))
14
+
15
+ const axios = require('axios')
16
+ const res = await axios.post(
17
+ 'https://snapsave.app/action.php?lang=id',
18
+ 'url=' + encodeURIComponent(url),
19
+ {
20
+ headers: {
21
+ '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',
22
+ 'Referer': 'https://snapsave.app/id/download-video-instagram',
23
+ 'Origin': 'https://snapsave.app',
24
+ 'Content-Type': 'application/x-www-form-urlencoded',
25
+ },
26
+ timeout: 15000,
27
+ }
28
+ )
29
+
30
+ // Decode obfuscated JS
31
+ let decoded = ''
32
+ const sandbox = {
33
+ decodeURIComponent, escape, unescape, String, Math,
34
+ window: { location: { hostname: 'snapsave.app' } },
35
+ document: { getElementById: () => ({ set innerHTML(v) { decoded = v } }) },
36
+ eval: (s) => { decoded = s; return s },
37
+ }
38
+ try { vm.runInNewContext(res.data, sandbox) } catch (_) {}
39
+
40
+ if (!decoded) return resolve(fail('Gagal decode response'))
41
+
42
+ // Unescape escaped quotes dari dalam JS string
43
+ decoded = decoded.replace(/\\"/g, '"').replace(/\\'/g, "'")
44
+
45
+ const $ = cheerio.load(decoded)
46
+ const medias = []
47
+
48
+ $('.download-items').each((_, el) => {
49
+ const thumb = $(el).find('img').attr('src') || null
50
+ const dlUrl = $(el).find('a[href*="rapidcdn"]').attr('href') || null
51
+ const isVideo = $(el).find('i[class*="dlvideo"]').length > 0
52
+ if (dlUrl) medias.push({ type: isVideo ? 'video' : 'image', url: dlUrl, thumbnail: thumb })
53
+ })
54
+
55
+ if (!medias.length) return resolve(fail('Media tidak ditemukan'))
56
+
57
+ resolve(ok({ url, medias }))
58
+ } catch (e) { console.log(e); resolve(fail(e)) }
59
+ })
60
+ }
61
+
62
+ module.exports = { instagram }
@@ -0,0 +1,30 @@
1
+ const { fetchHTML, cheerio, ok, fail } = require('../utils')
2
+
3
+ /*
4
+ * MediaFire Downloader
5
+ * @param {string} url - URL MediaFire
6
+ */
7
+ const mediafire = async (url) => {
8
+ return new Promise(async (resolve) => {
9
+ try {
10
+ if (!url || !url.includes('mediafire.com'))
11
+ return resolve(fail('URL MediaFire tidak valid'))
12
+
13
+ const html = await fetchHTML(url, { Referer: 'https://www.mediafire.com/' })
14
+ const $ = cheerio.load(html)
15
+
16
+ const download = $('#downloadButton').attr('href')
17
+ if (!download) return resolve(fail('Link download tidak ditemukan'))
18
+
19
+ const filename = $('.dl-btn-label').attr('title') ||
20
+ $('meta[property="og:title"]').attr('content') ||
21
+ download.split('/').pop()
22
+ const filesize = $('#downloadButton').text().replace(/Download/i, '').replace(/\s+/g, ' ').trim()
23
+ const filetype = filename.split('.').pop() || 'unknown'
24
+
25
+ resolve(ok({ url, filename, filesize, filetype, download }))
26
+ } catch (e) { console.log(e); resolve(fail(e)) }
27
+ })
28
+ }
29
+
30
+ module.exports = { mediafire }
@@ -0,0 +1,262 @@
1
+ const { axios, fetchHTML, ok, fail } = require('../utils')
2
+
3
+ const HEADERS = {
4
+ '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',
5
+ 'Accept-Language': 'id-ID,id;q=0.9',
6
+ }
7
+
8
+ const _id = (url, type) => url.match(new RegExp(`spotify\\.com\\/${type}\\/([a-zA-Z0-9]+)`))?.[1] || null
9
+
10
+ const _state = (html) => {
11
+ try {
12
+ const m = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)
13
+ if (m) return JSON.parse(m[1])
14
+ } catch (_) {}
15
+ return null
16
+ }
17
+
18
+ const _cover = (entity) => {
19
+ const imgs = entity?.visualIdentity?.image || entity?.coverArt?.sources || entity?.images || []
20
+ if (!imgs.length) return null
21
+ return imgs.sort((a, b) => (b.maxWidth || b.width || 0) - (a.maxWidth || a.width || 0))[0]?.url || null
22
+ }
23
+
24
+ const _duration = (ms) => {
25
+ if (!ms || isNaN(ms)) return null
26
+ const m = Math.floor(ms / 60000)
27
+ const s = Math.floor((ms % 60000) / 1000).toString().padStart(2, '0')
28
+ return `${m}:${s}`
29
+ }
30
+
31
+ // Ambil anonymous access token dari Spotify web player
32
+ const _getToken = async () => {
33
+ const res = await axios.get('https://open.spotify.com/get_access_token?reason=transport&productType=web_player', {
34
+ headers: { ...HEADERS, 'Referer': 'https://open.spotify.com/' },
35
+ timeout: 10000,
36
+ })
37
+ return res.data?.accessToken || null
38
+ }
39
+
40
+ // Cari video YouTube pertama dari query
41
+ const _ytSearch = async (query) => {
42
+ const html = await fetchHTML(
43
+ `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`,
44
+ { 'User-Agent': HEADERS['User-Agent'], 'Accept-Language': 'en-US,en;q=0.9' }
45
+ )
46
+ const m = html.match(/var ytInitialData\s*=\s*({[\s\S]*?});<\/script>/)
47
+ if (!m) return null
48
+ try {
49
+ const data = JSON.parse(m[1])
50
+ const sections = data?.contents?.twoColumnSearchResultsRenderer
51
+ ?.primaryContents?.sectionListRenderer?.contents || []
52
+ for (const sec of sections) {
53
+ for (const item of sec?.itemSectionRenderer?.contents || []) {
54
+ if (item?.videoRenderer?.videoId) return item.videoRenderer.videoId
55
+ }
56
+ }
57
+ } catch (_) {}
58
+ return null
59
+ }
60
+
61
+ // Download audio via cobalt → fallback yt1s
62
+ const _ytDownload = async (videoId) => {
63
+ const ytUrl = `https://www.youtube.com/watch?v=${videoId}`
64
+ // Cobalt
65
+ try {
66
+ const res = await axios.post('https://co.wuk.sh/api/json',
67
+ { url: ytUrl, isAudioOnly: true, aFormat: 'mp3', filenamePattern: 'basic' },
68
+ { headers: { ...HEADERS, 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 20000 }
69
+ )
70
+ if (res.data?.url) return { url: res.data.url, source: 'cobalt' }
71
+ } catch (_) {}
72
+ // yt1s fallback
73
+ try {
74
+ const page = await axios.post('https://yt1s.com/api/ajaxSearch',
75
+ new URLSearchParams({ q: ytUrl, vt: 'home' }).toString(),
76
+ { headers: { ...HEADERS, 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': 'https://yt1s.com/' }, timeout: 15000 }
77
+ )
78
+ const kId = page.data?.kId
79
+ const bid = Object.keys(page.data?.links?.mp3 || {})[0]
80
+ if (kId && bid) {
81
+ const conv = await axios.post('https://yt1s.com/api/ajaxConvert',
82
+ new URLSearchParams({ vid: videoId, k: bid }).toString(),
83
+ { headers: { ...HEADERS, 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': 'https://yt1s.com/' }, timeout: 20000 }
84
+ )
85
+ if (conv.data?.dlink) return { url: conv.data.dlink, source: 'yt1s' }
86
+ }
87
+ } catch (_) {}
88
+ return null
89
+ }
90
+
91
+ /*
92
+ * Spotify Search Track
93
+ * @param {string} query
94
+ * @param {number} limit
95
+ */
96
+ const spotifySearch = async (query, limit = 10) => {
97
+ return new Promise(async (resolve) => {
98
+ try {
99
+ if (!query) return resolve(fail('Query tidak boleh kosong'))
100
+
101
+ const token = await _getToken()
102
+ if (!token) return resolve(fail('Gagal mendapatkan token Spotify'))
103
+
104
+ const res = await axios.get(`https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=${limit}`, {
105
+ headers: { ...HEADERS, 'Authorization': `Bearer ${token}` },
106
+ timeout: 15000,
107
+ })
108
+
109
+ const items = res.data?.tracks?.items || []
110
+ if (!items.length) return resolve(fail('Tidak ada hasil'))
111
+
112
+ resolve(ok(items.map(t => ({
113
+ id: t.id,
114
+ title: t.name,
115
+ artist: t.artists?.map(a => a.name).join(', ') || null,
116
+ album: t.album?.name || null,
117
+ release: t.album?.release_date || null,
118
+ duration: _duration(t.duration_ms),
119
+ cover: t.album?.images?.[0]?.url || null,
120
+ preview: t.preview_url || null,
121
+ url: t.external_urls?.spotify || `https://open.spotify.com/track/${t.id}`,
122
+ }))))
123
+ } catch (e) { console.log(e); resolve(fail(e)) }
124
+ })
125
+ }
126
+
127
+ /*
128
+ * Spotify Track Info + Preview URL (30 detik)
129
+ * @param {string} url - URL track Spotify
130
+ */
131
+ const spotifyTrack = async (url) => {
132
+ return new Promise(async (resolve) => {
133
+ try {
134
+ if (!url || !url.includes('spotify.com'))
135
+ return resolve(fail('URL Spotify tidak valid'))
136
+
137
+ const id = _id(url, 'track')
138
+ if (!id) return resolve(fail('Track ID tidak ditemukan'))
139
+
140
+ const html = await fetchHTML(`https://open.spotify.com/embed/track/${id}`, HEADERS)
141
+ const entity = _state(html)?.props?.pageProps?.state?.data?.entity
142
+ if (!entity) return resolve(fail('Data track tidak ditemukan'))
143
+
144
+ resolve(ok({
145
+ id,
146
+ title: entity.name || entity.title || null,
147
+ artist: (entity.artists || []).map(a => a.name).join(', ') || null,
148
+ release: entity.releaseDate?.isoString?.slice(0, 10) || null,
149
+ duration: _duration(entity.duration),
150
+ cover: _cover(entity),
151
+ preview: entity.audioPreview?.url || null,
152
+ url: `https://open.spotify.com/track/${id}`,
153
+ }))
154
+ } catch (e) { console.log(e); resolve(fail(e)) }
155
+ })
156
+ }
157
+
158
+ /*
159
+ * Spotify Download — cari di YouTube lalu download audio
160
+ * @param {string} url - URL track Spotify
161
+ */
162
+ const spotifyDownload = async (url) => {
163
+ return new Promise(async (resolve) => {
164
+ try {
165
+ const track = await spotifyTrack(url)
166
+ if (!track.status) return resolve(track)
167
+
168
+ const { title, artist } = track.data
169
+ const query = `${title} ${artist} audio`
170
+
171
+ const ytId = await _ytSearch(query)
172
+ if (!ytId) return resolve(fail('Video YouTube tidak ditemukan'))
173
+
174
+ const dl = await _ytDownload(ytId)
175
+
176
+ resolve(ok({
177
+ ...track.data,
178
+ youtube_id: ytId,
179
+ youtube_url: `https://www.youtube.com/watch?v=${ytId}`,
180
+ download: dl?.url || null,
181
+ dl_source: dl?.source || null,
182
+ }))
183
+ } catch (e) { console.log(e); resolve(fail(e)) }
184
+ })
185
+ }
186
+
187
+ /*
188
+ * Spotify Album Info + list track (ringan)
189
+ * @param {string} url - URL album Spotify
190
+ */
191
+ const spotifyAlbum = async (url) => {
192
+ return new Promise(async (resolve) => {
193
+ try {
194
+ if (!url || !url.includes('spotify.com'))
195
+ return resolve(fail('URL Spotify tidak valid'))
196
+
197
+ const id = _id(url, 'album')
198
+ if (!id) return resolve(fail('Album ID tidak ditemukan'))
199
+
200
+ const html = await fetchHTML(`https://open.spotify.com/embed/album/${id}`, HEADERS)
201
+ const entity = _state(html)?.props?.pageProps?.state?.data?.entity
202
+ if (!entity) return resolve(fail('Data album tidak ditemukan'))
203
+
204
+ const tracks = (entity.tracks?.items || []).map(t => ({
205
+ id: t.track?.id || t.uid || null,
206
+ title: t.track?.name || t.name || null,
207
+ artist: (t.track?.artists || t.artists || []).map(a => a.name).join(', '),
208
+ duration: _duration(t.track?.duration?.totalMilliseconds || t.track?.duration || t.duration),
209
+ }))
210
+
211
+ resolve(ok({
212
+ id,
213
+ title: entity.name || null,
214
+ artist: (entity.artists || []).map(a => a.name).join(', ') || null,
215
+ release: entity.date?.isoString?.slice(0, 10) || entity.releaseDate?.isoString?.slice(0, 10) || null,
216
+ total_track: entity.tracks?.totalCount || tracks.length,
217
+ cover: _cover(entity),
218
+ tracks,
219
+ url: `https://open.spotify.com/album/${id}`,
220
+ }))
221
+ } catch (e) { console.log(e); resolve(fail(e)) }
222
+ })
223
+ }
224
+
225
+ /*
226
+ * Spotify Playlist Info + list track (ringan)
227
+ * @param {string} url - URL playlist Spotify
228
+ */
229
+ const spotifyPlaylist = async (url) => {
230
+ return new Promise(async (resolve) => {
231
+ try {
232
+ if (!url || !url.includes('spotify.com'))
233
+ return resolve(fail('URL Spotify tidak valid'))
234
+
235
+ const id = _id(url, 'playlist')
236
+ if (!id) return resolve(fail('Playlist ID tidak ditemukan'))
237
+
238
+ const html = await fetchHTML(`https://open.spotify.com/embed/playlist/${id}`, HEADERS)
239
+ const entity = _state(html)?.props?.pageProps?.state?.data?.entity
240
+ if (!entity) return resolve(fail('Data playlist tidak ditemukan'))
241
+
242
+ const tracks = (entity.trackList || entity.tracks?.items || []).map(t => ({
243
+ id: t.uid || t.track?.id || null,
244
+ title: t.track?.name || t.name || null,
245
+ artist: (t.track?.artists || t.artists || []).map(a => a.name).join(', '),
246
+ }))
247
+
248
+ resolve(ok({
249
+ id,
250
+ title: entity.name || null,
251
+ owner: entity.ownerV2?.data?.name || null,
252
+ description: entity.description || null,
253
+ total_track: entity.tracks?.totalCount || tracks.length,
254
+ cover: _cover(entity),
255
+ tracks,
256
+ url: `https://open.spotify.com/playlist/${id}`,
257
+ }))
258
+ } catch (e) { console.log(e); resolve(fail(e)) }
259
+ })
260
+ }
261
+
262
+ module.exports = { spotifySearch, spotifyTrack, spotifyDownload, spotifyAlbum, spotifyPlaylist }