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. A developer has built a Node.js/ES Modules-based TikTok downloader and scraper that can automatically detect and download regular videos, MP3 audio, or photo slides. The tool features automatic slide-to-MP4 conversion via WebSocket, using cheerio for HTML parsing and the savetik.io API to extract content. It parses TikTok URLs to identify content type and retrieves download links for videos, audio, and individual slide images. | 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 ; |