{"slug": "tiktok-downloader-scraper-node-js-es-modules-auto-detect-and-download-regular-or", "title": "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.", "summary": "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.", "body_md": "|\nimport * as cheerio from 'cheerio'; |\n|\nimport fs from 'fs/promises'; |\n|\nimport WebSocket from 'ws'; |\n|\nimport { createWriteStream } from 'fs'; |\n|\n|\n|\nconst decodeHtmlEntities = (str) => { |\n|\nif (!str) return str; |\n|\nreturn str.replace(/+/gi, '+') |\n|\n.replace(/=/gi, '=') |\n|\n.replace(/&/gi, '&') |\n|\n.replace(/&/g, '&'); |\n|\n}; |\n|\n|\n|\nconst downloadTikTok = async (tiktokUrl) => { |\n|\nconst url = 'https://savetik.io/api/ajaxSearch'; |\n|\n|\n|\nconst headers = { |\n|\n'Accept': '*/*', |\n|\n'Accept-Encoding': 'gzip, deflate, br, zstd', |\n|\n'Accept-Language': 'id-ID', |\n|\n'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', |\n|\n'Origin': 'https://savetik.io', |\n|\n'Referer': 'https://savetik.io/en', |\n|\n'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', |\n|\n}; |\n|\n|\n|\nconst formData = new URLSearchParams({ |\n|\nq: tiktokUrl, |\n|\ncursor: '0', |\n|\npage: '0', |\n|\nlang: 'en' |\n|\n}); |\n|\n|\n|\ntry { |\n|\nconst response = await fetch(url, { |\n|\nmethod: 'POST', |\n|\nheaders: headers, |\n|\nbody: formData.toString() |\n|\n}); |\n|\n|\n|\nif (!response.ok) { |\n|\nthrow new Error(`HTTP error! Status: ${response.status}`); |\n|\n} |\n|\n|\n|\nconst result = await response.json(); |\n|\n|\n|\nawait fs.writeFile('hasil_data.txt', JSON.stringify(result, null, 2), 'utf-8'); |\n|\n|\n|\nif (result.status !== 'ok' || !result.data) { |\n|\nthrow new Error('Gagal mendapatkan data HTML yang valid dari server.'); |\n|\n} |\n|\n|\n|\nconst $ = cheerio.load(result.data); |\n|\nconst parsedData = {}; |\n|\n|\n|\nparsedData.title = $('h3').first().text().trim() || null; |\n|\nparsedData.thumbnail = $('.thumbnail .image-tik img').attr('src') || |\n|\n$('.thumbnail img').first().attr('src') || |\n|\nnull; |\n|\n|\n|\nparsedData.videos = []; |\n|\n$('.dl-action a').each((i, el) => { |\n|\nconst text = $(el).text().trim(); |\n|\nconst href = $(el).attr('href'); |\n|\nif (text.includes('Download MP4') || text.includes('Download Video')) { |\n|\nparsedData.videos.push({ |\n|\nlabel: text.replace(/\\s+/g, ' '), |\n|\nurl: href |\n|\n}); |\n|\n} |\n|\n}); |\n|\nparsedData.videoUrl = parsedData.videos[0]?.url || null; |\n|\n|\n|\nlet mp3Link = null; |\n|\n$('.dl-action a').each((i, el) => { |\n|\nconst text = $(el).text().trim(); |\n|\nif (text.includes('Download MP3')) { |\n|\nmp3Link = $(el).attr('href'); |\n|\nreturn false; |\n|\n} |\n|\n}); |\n|\nparsedData.mp3Url = mp3Link; |\n|\n|\n|\nparsedData.photos = []; |\n|\n$('.download-box li, .download-items').each((index, element) => { |\n|\nconst thumb = $(element).find('.download-items__thumb img').attr('src'); |\n|\nconst downloadLink = $(element).find('.download-items__btn a').attr('href'); |\n|\nif (downloadLink) { |\n|\nparsedData.photos.push({ |\n|\nindex: index + 1, |\n|\nthumbnail: thumb || null, |\n|\ndownloadUrl: downloadLink |\n|\n}); |\n|\n} |\n|\n}); |\n|\n|\n|\nconst renderElem = $('#ConvertToVideo').length ? $('#ConvertToVideo') : $('[data-audiourl]'); |\n|\nlet rawAudioUrl = renderElem.attr('data-audiourl'); |\n|\nlet rawImageData = renderElem.attr('data-imagedata'); |\n|\n|\n|\nparsedData.audioUrl = rawAudioUrl ? decodeHtmlEntities(rawAudioUrl) : null; |\n|\nparsedData.imageDataUrl = rawImageData ? decodeHtmlEntities(rawImageData) : null; |\n|\nparsedData.contentType = parsedData.photos.length > 0 ? 'slide' : 'video'; |\n|\nparsedData.tiktokId = $('#TikTokId').val() || null; |\n|\n|\n|\nconst scriptText = $('script').text(); |\n|\nparsedData.k_exp = scriptText.match(/k_exp\\s*=\\s*\"([^\"]+)\"/)?.[1] || null; |\n|\nparsedData.k_token = scriptText.match(/k_token\\s*=\\s*\"([^\"]+)\"/)?.[1] || null; |\n|\nparsedData.k_url_convert = scriptText.match(/k_url_convert\\s*=\\s*\"([^\"]+)\"/)?.[1] || null; |\n|\n|\n|\nawait fs.writeFile('hasil_clean.txt', JSON.stringify(parsedData, null, 2), 'utf-8'); |\n|\n|\n|\nreturn parsedData; |\n|\n|\n|\n} catch (error) { |\n|\nconsole.error('Gagal memproses downloadTikTok:', error.message); |\n|\nthrow error; |\n|\n} |\n|\n}; |\n|\n|\n|\nconst convertSlideToVideo = async (parsedData) => { |\n|\nif (!parsedData || parsedData.contentType !== 'slide') { |\n|\nconsole.log('[-] Batalkan konversi: Konten ini bukan bertipe slide/foto.'); |\n|\nreturn null; |\n|\n} |\n|\n|\n|\nif (!parsedData.audioUrl || !parsedData.imageDataUrl) { |\n|\nconsole.log('[-] Batalkan konversi: audioUrl atau imageDataUrl tidak ditemukan.'); |\n|\nreturn null; |\n|\n} |\n|\n|\n|\nconst url = parsedData.k_url_convert || 'https://s3.tik-cdn.com/api/json/convert'; |\n|\n|\n|\nconst headers = { |\n|\n'Accept': '*/*', |\n|\n'Accept-Encoding': 'gzip, deflate, br, zstd', |\n|\n'Accept-Language': 'id-ID', |\n|\n'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', |\n|\n'Origin': 'https://savetik.io', |\n|\n'Referer': 'https://savetik.io/', |\n|\n'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', |\n|\n'Sec-Ch-Ua': '\"Chromium\";v=\"127\", \"Not)A;Brand\";v=\"99\", \"Microsoft Edge Simulate\";v=\"127\", \"Lemur\";v=\"127\"', |\n|\n'Sec-Ch-Ua-Mobile': '?0', |\n|\n'Sec-Ch-Ua-Platform': '\"Android\"', |\n|\n'Sec-Fetch-Dest': 'empty', |\n|\n'Sec-Fetch-Mode': 'cors', |\n|\n'Sec-Fetch-Site': 'cross-site' |\n|\n}; |\n|\n|\n|\nconst formData = new URLSearchParams({ |\n|\nftype: 'mp4', |\n|\nv_id: parsedData.tiktokId, |\n|\naudioUrl: parsedData.audioUrl, |\n|\naudioType: 'audio/mp3', |\n|\nimageUrl: parsedData.imageDataUrl, |\n|\nfquality: '1080p', |\n|\nfname: 'SaveTik.io', |\n|\nexp: parsedData.k_exp, |\n|\ntoken: parsedData.k_token |\n|\n}); |\n|\n|\n|\ntry { |\n|\nconsole.log('[+] Mengirim permintaan konversi slide ke MP4...'); |\n|\nconst response = await fetch(url, { |\n|\nmethod: 'POST', |\n|\nheaders: headers, |\n|\nbody: formData.toString() |\n|\n}); |\n|\n|\n|\nif (!response.ok) { |\n|\nthrow new Error(`Gagal menghubungi server convert. Status: ${response.status}`); |\n|\n} |\n|\n|\n|\nconst convertResult = await response.json(); |\n|\nconsole.log('[+] Hasil Respon Convert:', convertResult); |\n|\n|\n|\nawait fs.writeFile('hasil_convert.txt', JSON.stringify(convertResult, null, 2), 'utf-8'); |\n|\nconsole.log('[+] Sukses! Log convert disimpan di \"hasil_convert.txt\"'); |\n|\n|\n|\nreturn convertResult; |\n|\n|\n|\n} catch (error) { |\n|\nconsole.error('[-] Terjadi kesalahan saat mengonversi slide:', error.message); |\n|\nthrow error; |\n|\n} |\n|\n}; |\n|\n|\n|\nconst downloadConvertedVideo = (jobId, outputFilename = 'converted_video.mp4') => { |\n|\nreturn new Promise((resolve, reject) => { |\n|\nconst wsUrl = `wss://s3.tik-cdn.com/sub/${jobId}?fname=SaveTik.io`; |\n|\nconsole.log(`[+] Menghubungi WebSocket: ${wsUrl}`); |\n|\n|\n|\nconst headers = { |\n|\n'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Mobile Safari/537.36', |\n|\n'Origin': 'https://savetik.io', |\n|\n'Cache-Control': 'no-cache', |\n|\n'Pragma': 'no-cache', |\n|\n'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits' |\n|\n}; |\n|\n|\n|\nconst ws = new WebSocket(wsUrl, { headers }); |\n|\nconst writeStream = createWriteStream(outputFilename); |\n|\nlet receivedBytes = 0; |\n|\nlet timeoutId; |\n|\n|\n|\nws.on('open', () => { |\n|\nconsole.log('[+] WebSocket terbuka, menunggu data video...'); |\n|\ntimeoutId = setTimeout(() => { |\n|\nws.terminate(); |\n|\nreject(new Error('Timeout: tidak ada data dari WebSocket dalam 60 detik')); |\n|\n}, 60000); |\n|\n}); |\n|\n|\n|\nws.on('message', (data, isBinary) => { |\n|\nif (isBinary) { |\n|\nreceivedBytes += data.length; |\n|\nwriteStream.write(Buffer.from(data)); |\n|\nconsole.log(`[+] Menerima chunk binary: ${data.length} bytes (total: ${receivedBytes})`); |\n|\n} else { |\n|\nconst msg = data.toString(); |\n|\nconsole.log(`[+] Pesan teks: ${msg}`); |\n|\nif (msg.includes('end') || msg.includes('complete')) { |\n|\nws.close(); |\n|\n} |\n|\n} |\n|\n}); |\n|\n|\n|\nws.on('close', (code, reason) => { |\n|\nclearTimeout(timeoutId); |\n|\nwriteStream.end(); |\n|\nconsole.log(`[+] WebSocket ditutup: ${code} - ${reason}`); |\n|\nif (receivedBytes === 0) { |\n|\nreject(new Error('Tidak ada data video yang diterima')); |\n|\n} else { |\n|\nconsole.log(`[+] Video berhasil disimpan ke ${outputFilename} (${receivedBytes} bytes)`); |\n|\nresolve(outputFilename); |\n|\n} |\n|\n}); |\n|\n|\n|\nws.on('error', (err) => { |\n|\nclearTimeout(timeoutId); |\n|\nwriteStream.end(); |\n|\nreject(err); |\n|\n}); |\n|\n}); |\n|\n}; |\n|\n|\n|\nconst init = async () => { |\n|\nconst targetUrl = 'https://vm.tiktok.com/ZS926oGFNUuYw-uz16y/'; |\n|\n|\n|\nconsole.log('[+] Memulai Scrape data dari Savetik...'); |\n|\nconst data = await downloadTikTok(targetUrl); |\n|\nconsole.log('[+] Tipe Konten Terdeteksi:', data.contentType); |\n|\n|\n|\nif (data.contentType === 'slide') { |\n|\nconst convertResult = await convertSlideToVideo(data); |\n|\nif (convertResult && convertResult.status === 'success' && convertResult.jobId) { |\n|\nconst jobId = convertResult.jobId; |\n|\nconsole.log(`[+] Job ID diterima: ${jobId}`); |\n|\nawait new Promise(resolve => setTimeout(resolve, 3000)); |\n|\nawait downloadConvertedVideo(jobId, 'slide_to_video.mp4'); |\n|\n} else { |\n|\nconsole.log('[-] Gagal mendapatkan jobId dari server konversi.'); |\n|\n} |\n|\n} else { |\n|\nconsole.log('[+] Link tersebut adalah Video reguler. Opsi MP4 langsung:', data.videos); |\n|\n} |\n|\n}; |\n|\n|\n|\ninit().catch(console.error); |", "url": "https://wpnews.pro/news/tiktok-downloader-scraper-node-js-es-modules-auto-detect-and-download-regular-or", "canonical_source": "https://gist.github.com/HamzahSk/ddbc7222d45991238c90379c26f9f57b", "published_at": "2026-05-31 13:19:12+00:00", "updated_at": "2026-05-31 13:43:17.846667+00:00", "lang": "en", "topics": ["ai-tools"], "entities": ["Node.js", "TikTok", "Savetik.io", "WebSocket", "cheerio", "Mozilla"], "alternates": {"html": "https://wpnews.pro/news/tiktok-downloader-scraper-node-js-es-modules-auto-detect-and-download-regular-or", "markdown": "https://wpnews.pro/news/tiktok-downloader-scraper-node-js-es-modules-auto-detect-and-download-regular-or.md", "text": "https://wpnews.pro/news/tiktok-downloader-scraper-node-js-es-modules-auto-detect-and-download-regular-or.txt", "jsonld": "https://wpnews.pro/news/tiktok-downloader-scraper-node-js-es-modules-auto-detect-and-download-regular-or.jsonld"}}