# TikTok Downloader & Scraper (Node.js/ES Modules) - Auto-detect and download regular videos, MP3 audio, or photo slides. Features automatic slide-to-MP4 conversion via WebSocket.

> Source: <https://gist.github.com/HamzahSk/ddbc7222d45991238c90379c26f9f57b>
> Published: 2026-05-31 13:19:12+00:00

|
import * as cheerio from 'cheerio'; |
|
import fs from 'fs/promises'; |
|
import WebSocket from 'ws'; |
|
import { createWriteStream } from 'fs'; |
|
|
|
const decodeHtmlEntities = (str) => { |
|
if (!str) return str; |
|
return str.replace(/+/gi, '+') |
|
.replace(/=/gi, '=') |
|
.replace(/&/gi, '&') |
|
.replace(/&/g, '&'); |
|
}; |
|
|
|
const downloadTikTok = async (tiktokUrl) => { |
|
const url = 'https://savetik.io/api/ajaxSearch'; |
|
|
|
const headers = { |
|
'Accept': '*/*', |
|
'Accept-Encoding': 'gzip, deflate, br, zstd', |
|
'Accept-Language': 'id-ID', |
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', |
|
'Origin': 'https://savetik.io', |
|
'Referer': 'https://savetik.io/en', |
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', |
|
}; |
|
|
|
const formData = new URLSearchParams({ |
|
q: tiktokUrl, |
|
cursor: '0', |
|
page: '0', |
|
lang: 'en' |
|
}); |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'POST', |
|
headers: headers, |
|
body: formData.toString() |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`HTTP error! Status: ${response.status}`); |
|
} |
|
|
|
const result = await response.json(); |
|
|
|
await fs.writeFile('hasil_data.txt', JSON.stringify(result, null, 2), 'utf-8'); |
|
|
|
if (result.status !== 'ok' || !result.data) { |
|
throw new Error('Gagal mendapatkan data HTML yang valid dari server.'); |
|
} |
|
|
|
const $ = cheerio.load(result.data); |
|
const parsedData = {}; |
|
|
|
parsedData.title = $('h3').first().text().trim() || null; |
|
parsedData.thumbnail = $('.thumbnail .image-tik img').attr('src') || |
|
$('.thumbnail img').first().attr('src') || |
|
null; |
|
|
|
parsedData.videos = []; |
|
$('.dl-action a').each((i, el) => { |
|
const text = $(el).text().trim(); |
|
const href = $(el).attr('href'); |
|
if (text.includes('Download MP4') || text.includes('Download Video')) { |
|
parsedData.videos.push({ |
|
label: text.replace(/\s+/g, ' '), |
|
url: href |
|
}); |
|
} |
|
}); |
|
parsedData.videoUrl = parsedData.videos[0]?.url || null; |
|
|
|
let mp3Link = null; |
|
$('.dl-action a').each((i, el) => { |
|
const text = $(el).text().trim(); |
|
if (text.includes('Download MP3')) { |
|
mp3Link = $(el).attr('href'); |
|
return false; |
|
} |
|
}); |
|
parsedData.mp3Url = mp3Link; |
|
|
|
parsedData.photos = []; |
|
$('.download-box li, .download-items').each((index, element) => { |
|
const thumb = $(element).find('.download-items__thumb img').attr('src'); |
|
const downloadLink = $(element).find('.download-items__btn a').attr('href'); |
|
if (downloadLink) { |
|
parsedData.photos.push({ |
|
index: index + 1, |
|
thumbnail: thumb || null, |
|
downloadUrl: downloadLink |
|
}); |
|
} |
|
}); |
|
|
|
const renderElem = $('#ConvertToVideo').length ? $('#ConvertToVideo') : $('[data-audiourl]'); |
|
let rawAudioUrl = renderElem.attr('data-audiourl'); |
|
let rawImageData = renderElem.attr('data-imagedata'); |
|
|
|
parsedData.audioUrl = rawAudioUrl ? decodeHtmlEntities(rawAudioUrl) : null; |
|
parsedData.imageDataUrl = rawImageData ? decodeHtmlEntities(rawImageData) : null; |
|
parsedData.contentType = parsedData.photos.length > 0 ? 'slide' : 'video'; |
|
parsedData.tiktokId = $('#TikTokId').val() || null; |
|
|
|
const scriptText = $('script').text(); |
|
parsedData.k_exp = scriptText.match(/k_exp\s*=\s*"([^"]+)"/)?.[1] || null; |
|
parsedData.k_token = scriptText.match(/k_token\s*=\s*"([^"]+)"/)?.[1] || null; |
|
parsedData.k_url_convert = scriptText.match(/k_url_convert\s*=\s*"([^"]+)"/)?.[1] || null; |
|
|
|
await fs.writeFile('hasil_clean.txt', JSON.stringify(parsedData, null, 2), 'utf-8'); |
|
|
|
return parsedData; |
|
|
|
} catch (error) { |
|
console.error('Gagal memproses downloadTikTok:', error.message); |
|
throw error; |
|
} |
|
}; |
|
|
|
const convertSlideToVideo = async (parsedData) => { |
|
if (!parsedData || parsedData.contentType !== 'slide') { |
|
console.log('[-] Batalkan konversi: Konten ini bukan bertipe slide/foto.'); |
|
return null; |
|
} |
|
|
|
if (!parsedData.audioUrl || !parsedData.imageDataUrl) { |
|
console.log('[-] Batalkan konversi: audioUrl atau imageDataUrl tidak ditemukan.'); |
|
return null; |
|
} |
|
|
|
const url = parsedData.k_url_convert || 'https://s3.tik-cdn.com/api/json/convert'; |
|
|
|
const headers = { |
|
'Accept': '*/*', |
|
'Accept-Encoding': 'gzip, deflate, br, zstd', |
|
'Accept-Language': 'id-ID', |
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', |
|
'Origin': 'https://savetik.io', |
|
'Referer': 'https://savetik.io/', |
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', |
|
'Sec-Ch-Ua': '"Chromium";v="127", "Not)A;Brand";v="99", "Microsoft Edge Simulate";v="127", "Lemur";v="127"', |
|
'Sec-Ch-Ua-Mobile': '?0', |
|
'Sec-Ch-Ua-Platform': '"Android"', |
|
'Sec-Fetch-Dest': 'empty', |
|
'Sec-Fetch-Mode': 'cors', |
|
'Sec-Fetch-Site': 'cross-site' |
|
}; |
|
|
|
const formData = new URLSearchParams({ |
|
ftype: 'mp4', |
|
v_id: parsedData.tiktokId, |
|
audioUrl: parsedData.audioUrl, |
|
audioType: 'audio/mp3', |
|
imageUrl: parsedData.imageDataUrl, |
|
fquality: '1080p', |
|
fname: 'SaveTik.io', |
|
exp: parsedData.k_exp, |
|
token: parsedData.k_token |
|
}); |
|
|
|
try { |
|
console.log('[+] Mengirim permintaan konversi slide ke MP4...'); |
|
const response = await fetch(url, { |
|
method: 'POST', |
|
headers: headers, |
|
body: formData.toString() |
|
}); |
|
|
|
if (!response.ok) { |
|
throw new Error(`Gagal menghubungi server convert. Status: ${response.status}`); |
|
} |
|
|
|
const convertResult = await response.json(); |
|
console.log('[+] Hasil Respon Convert:', convertResult); |
|
|
|
await fs.writeFile('hasil_convert.txt', JSON.stringify(convertResult, null, 2), 'utf-8'); |
|
console.log('[+] Sukses! Log convert disimpan di "hasil_convert.txt"'); |
|
|
|
return convertResult; |
|
|
|
} catch (error) { |
|
console.error('[-] Terjadi kesalahan saat mengonversi slide:', error.message); |
|
throw error; |
|
} |
|
}; |
|
|
|
const downloadConvertedVideo = (jobId, outputFilename = 'converted_video.mp4') => { |
|
return new Promise((resolve, reject) => { |
|
const wsUrl = `wss://s3.tik-cdn.com/sub/${jobId}?fname=SaveTik.io`; |
|
console.log(`[+] Menghubungi WebSocket: ${wsUrl}`); |
|
|
|
const headers = { |
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', |
|
'Origin': 'https://savetik.io', |
|
'Cache-Control': 'no-cache', |
|
'Pragma': 'no-cache', |
|
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits' |
|
}; |
|
|
|
const ws = new WebSocket(wsUrl, { headers }); |
|
const writeStream = createWriteStream(outputFilename); |
|
let receivedBytes = 0; |
|
let timeoutId; |
|
|
|
ws.on('open', () => { |
|
console.log('[+] WebSocket terbuka, menunggu data video...'); |
|
timeoutId = setTimeout(() => { |
|
ws.terminate(); |
|
reject(new Error('Timeout: tidak ada data dari WebSocket dalam 60 detik')); |
|
}, 60000); |
|
}); |
|
|
|
ws.on('message', (data, isBinary) => { |
|
if (isBinary) { |
|
receivedBytes += data.length; |
|
writeStream.write(Buffer.from(data)); |
|
console.log(`[+] Menerima chunk binary: ${data.length} bytes (total: ${receivedBytes})`); |
|
} else { |
|
const msg = data.toString(); |
|
console.log(`[+] Pesan teks: ${msg}`); |
|
if (msg.includes('end') || msg.includes('complete')) { |
|
ws.close(); |
|
} |
|
} |
|
}); |
|
|
|
ws.on('close', (code, reason) => { |
|
clearTimeout(timeoutId); |
|
writeStream.end(); |
|
console.log(`[+] WebSocket ditutup: ${code} - ${reason}`); |
|
if (receivedBytes === 0) { |
|
reject(new Error('Tidak ada data video yang diterima')); |
|
} else { |
|
console.log(`[+] Video berhasil disimpan ke ${outputFilename} (${receivedBytes} bytes)`); |
|
resolve(outputFilename); |
|
} |
|
}); |
|
|
|
ws.on('error', (err) => { |
|
clearTimeout(timeoutId); |
|
writeStream.end(); |
|
reject(err); |
|
}); |
|
}); |
|
}; |
|
|
|
const init = async () => { |
|
const targetUrl = 'https://vm.tiktok.com/ZS926oGFNUuYw-uz16y/'; |
|
|
|
console.log('[+] Memulai Scrape data dari Savetik...'); |
|
const data = await downloadTikTok(targetUrl); |
|
console.log('[+] Tipe Konten Terdeteksi:', data.contentType); |
|
|
|
if (data.contentType === 'slide') { |
|
const convertResult = await convertSlideToVideo(data); |
|
if (convertResult && convertResult.status === 'success' && convertResult.jobId) { |
|
const jobId = convertResult.jobId; |
|
console.log(`[+] Job ID diterima: ${jobId}`); |
|
await new Promise(resolve => setTimeout(resolve, 3000)); |
|
await downloadConvertedVideo(jobId, 'slide_to_video.mp4'); |
|
} else { |
|
console.log('[-] Gagal mendapatkan jobId dari server konversi.'); |
|
} |
|
} else { |
|
console.log('[+] Link tersebut adalah Video reguler. Opsi MP4 langsung:', data.videos); |
|
} |
|
}; |
|
|
|
init().catch(console.error); |
