From c7af6991c31c5c9abcd7764d0a68b6396962808b Mon Sep 17 00:00:00 2001 From: Tobi Saputra Date: Mon, 3 Mar 2025 20:54:38 +0700 Subject: [PATCH] feat: add cli for download, stalk, search, get comments --- src/cli/index.ts | 255 +++++++++++++++++++++++++++++++++++ src/utils/downloadManager.ts | 111 +++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 src/cli/index.ts create mode 100644 src/utils/downloadManager.ts diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..e49f75f --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,255 @@ +#!/usr/bin/env node + +import { program } from "commander" +import Tiktok from ".." +import { CookieManager } from "../utils/cookieManager" +import { Logger } from "../utils/logger" +import chalk from "chalk" +import { + getDefaultDownloadPath, + handleMediaDownload +} from "../utils/downloadManager" + +const cookieManager = new CookieManager("tiktok") + +program + .name("tiktokdl") + .description("TikTok downloader and search CLI tool") + .version("1.0.0") + +program + .command("download") + .description("Download TikTok Video / Slide / Music") + .argument("", "TikTok Video / Slide / Music URL") + .option("-o, --output ", "Output directory path") + .option("-v, --version ", "Downloader version (v1/v2/v3)", "v1") + .option("-p, --proxy ", "Proxy URL (http/https/socks)") + .action(async (url, options) => { + try { + const outputPath = options.output || getDefaultDownloadPath() + const version = options.version.toLowerCase() + + if (!["v1", "v2", "v3"].includes(version)) { + throw new Error("Invalid version. Use v1, v2 or v3") + } + + Logger.info("Fetching media information...") + + const data = await Tiktok.Downloader(url, { + version: version as "v1" | "v2" | "v3", + proxy: options.proxy + }) + + await handleMediaDownload(data, outputPath, version) + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + +// Cookie Command + +const cookieCommand = program.command("cookie").description("Cookie Manager") + +cookieCommand + .command("set ") + .description("Set a cookie") + .action((value: string) => { + cookieManager.setCookie(value) + Logger.success("Cookie set successfully.") + }) + +cookieCommand + .command("get") + .description("Get cookie value") + .action(() => { + const cookie = cookieManager.getCookie() + if (cookie) { + Logger.info(`Cookie: ${cookie}`) + } else { + Logger.warning("No cookie found.") + } + }) + +cookieCommand + .command("delete") + .description("Delete cookie") + .action(() => { + cookieManager.deleteCookie() + Logger.success("Cookie deleted successfully.") + }) + +// Search Command + +const searchCommand = program + .command("search") + .description("Search TikTok users or live streams") + +searchCommand + .command("user") + .description("Search TikTok users") + .argument("", "Search keyword") + .option("-p, --page ", "Page number", "1") + .option("--proxy ", "Proxy URL (http/https/socks)") + .action(async (keyword, options) => { + try { + const page = parseInt(options.page) + const results = await Tiktok.Search(keyword, { + type: "user", + cookie: cookieManager.getCookie(), + page: page, + proxy: options.proxy + }) + if (results.status === "success") { + const data = results.result + for (const [index, user] of data.entries()) { + Logger.info(`---- USER ${index + 1} ----`) + Logger.result(`Username: ${user.username}`, chalk.green) + Logger.result(`Nickname: ${user.nickname}`, chalk.green) + Logger.result(`Bio: ${user.signature}`, chalk.green) + Logger.result(`Followers: ${user.followerCount}`, chalk.yellow) + Logger.result( + `Verified: ${user.isVerified ? "Yes" : "No"}`, + chalk.yellow + ) + Logger.result(`Profile URL: ${user.url}`, chalk.yellow) + } + Logger.info(`Total users: ${data.length}`) + } else { + Logger.error(`Error: ${results.message}`) + } + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + +searchCommand + .command("live") + .description("Search TikTok live streams") + .argument("", "Search keyword") + .option("-p, --page ", "Page number", "1") + .option("--proxy ", "Proxy URL (http/https/socks)") + .action(async (keyword, options) => { + try { + const page = parseInt(options.page) + const results = await Tiktok.Search(keyword, { + type: "live", + cookie: cookieManager.getCookie(), + page: page, + proxy: options.proxy + }) + if (results.status === "success") { + const data = results.result + for (const [index, live] of data.entries()) { + Logger.info(`---- LIVE ${index + 1} ----`) + Logger.result(`Title: ${live.title}`, chalk.green) + Logger.result(`Viewers: ${live.viewCount}`, chalk.yellow) + Logger.result(`Likes: ${live.likeCount}`, chalk.yellow) + Logger.result(`Comments: ${live.commentCount}`, chalk.yellow) + Logger.result(`Live URL: ${live.url}`, chalk.yellow) + } + Logger.info(`Total live streams: ${data.length}`) + } else { + Logger.error(`Error: ${results.message}`) + } + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + +// Get Comments Command + +program + .command("getcomments") + .description("Get comments from a TikTok video") + .argument("", "TikTok video URL") + .option("-l, --limit ", "Limit of comments", "10") + .option("-p, --proxy ", "Proxy URL (http/https/socks)") + .action(async (url, options) => { + try { + const limit = parseInt(options.limit) + const comments = await Tiktok.GetComments(url, { + commentLimit: limit, + proxy: options.proxy + }) + if (comments.status === "success") { + const data = comments.result + for (const [index, comment] of data.entries()) { + Logger.info(`---- COMMENT ${index + 1} ----`) + Logger.result(`Username: ${comment.user.username}`, chalk.green) + Logger.result(`Text: ${comment.text}`, chalk.green) + Logger.result(`Likes: ${comment.likeCount}`, chalk.yellow) + } + Logger.info(`Total comments: ${data.length}`) + } else { + Logger.error(`Error: ${comments.message}`) + } + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + +// Stalk Command + +program + .command("stalk") + .description("Stalk a TikTok user") + .argument("", "TikTok username") + .option("-p, --postLimit ", "Limit of posts", "5") + .option("--proxy ", "Proxy URL (http/https/socks)") + .action(async (username, options) => { + try { + const postLimit = parseInt(options.postLimit) + const results = await Tiktok.StalkUser(username, { + postLimit: postLimit, + proxy: options.proxy + }) + if (results.status === "success") { + const data = results.result + Logger.info("---- TIKTOK STALKER ----") + Logger.result(`Username:${data.users.username}`, chalk.green) + Logger.result(`Nickname:${data.users.nickname}`, chalk.green) + Logger.result(`Bio:${data.users.signature}`, chalk.green) + Logger.result( + `Verified:${data.users.verified ? "Yes" : "No"}`, + chalk.green + ) + Logger.result( + `Commerce User:${data.users.commerceUser ? "Yes" : "No"}`, + chalk.green + ) + Logger.result( + `Private Account:${data.users.privateAccount ? "Yes" : "No"}`, + chalk.green + ) + Logger.result(`Region:${data.users.region}`, chalk.green) + Logger.info("---- STATISTICS ----") + Logger.result(`Followers:${data.stats.followerCount}`, chalk.yellow) + Logger.result(`Following:${data.stats.followingCount}`, chalk.yellow) + Logger.result(`Hearts:${data.stats.heartCount}`, chalk.yellow) + Logger.result(`Videos:${data.stats.videoCount}`, chalk.yellow) + Logger.result(`Posts:${data.stats.postCount}`, chalk.yellow) + Logger.result(`Likes:${data.stats.likeCount}`, chalk.yellow) + Logger.result(`Friends:${data.stats.friendCount}`, chalk.yellow) + Logger.info("---- POSTS ----") + for (const [index, post] of data.posts.entries()) { + Logger.info(`---- POST ${index + 1} ----`) + Logger.result(`Title: ${post.desc}`, chalk.green) + Logger.result(`Likes: ${post.stats.diggCount}`, chalk.yellow) + Logger.result(`Comments: ${post.stats.commentCount}`, chalk.yellow) + Logger.result(`Shares: ${post.stats.shareCount}`, chalk.yellow) + Logger.result(`Views: ${post.stats.playCount}`, chalk.yellow) + Logger.result( + `Music: ${post.music.title} - ${post.music.authorName}`, + chalk.cyan + ) + Logger.result(`Music URL: ${post.music.playUrl}`, chalk.cyan) + } + } else { + Logger.error(`Error: ${results.message}`) + } + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + +program.parse() diff --git a/src/utils/downloadManager.ts b/src/utils/downloadManager.ts new file mode 100644 index 0000000..20c9dda --- /dev/null +++ b/src/utils/downloadManager.ts @@ -0,0 +1,111 @@ +import * as path from "path" +import * as os from "os" +import axios from "axios" +import * as fs from "fs" +import { Logger } from "./logger" + +function getDefaultDownloadPath(): string { + const homeDir = os.homedir() + return path.join(homeDir, "Downloads") +} + +async function downloadMedia( + url: string, + outputPath: string, + filename: string +): Promise { + try { + const response = await axios({ + method: "GET", + url: url, + responseType: "stream" + }) + + if (!fs.existsSync(outputPath)) { + fs.mkdirSync(outputPath, { recursive: true }) + } + + const writer = fs.createWriteStream(path.join(outputPath, filename)) + response.data.pipe(writer) + + return new Promise((resolve, reject) => { + writer.on("finish", resolve) + writer.on("error", reject) + }) + } catch (error) { + throw new Error(`Failed to download media: ${error.message}`) + } +} + +async function handleMediaDownload( + data: any, + outputPath: string, + version: string +): Promise { + if (data.status !== "success") { + throw new Error(data.message) + } + + const { result } = data + const author = result.author + const username = version === "v1" ? author.username : author.nickname + + Logger.success( + `${ + result.type.charAt(0).toUpperCase() + result.type.slice(1) + } Successfully Fetched!` + ) + Logger.info(`Media Type: ${result.type}`) + + switch (result.type) { + case "video": { + const videoUrl = + version === "v1" + ? result.video.downloadAddr[0] + : version === "v2" + ? result.video + : result.videoHD + const videoName = `ttdl_${username}_${Date.now()}.mp4` + + Logger.info("Downloading video...") + await downloadMedia(videoUrl, outputPath, videoName) + Logger.success( + `Video downloaded successfully to: ${path.join(outputPath, videoName)}` + ) + break + } + + case "image": { + const userOutputPath = path.join(outputPath, `${username}_${Date.now()}`) + const images = result.images + + for (let i = 0; i < images.length; i++) { + const imageName = `ttdl_${username}_${Date.now()}_${i + 1}.png` + Logger.info(`Downloading image ${i + 1}/${images.length}...`) + await downloadMedia(images[i], userOutputPath, imageName) + Logger.success( + `Image downloaded successfully to: ${path.join( + userOutputPath, + imageName + )}` + ) + } + break + } + + case "music": { + const musicName = `ttdl_${username}_${Date.now()}.mp3` + Logger.info("Downloading music...") + await downloadMedia(result.music, outputPath, musicName) + Logger.success( + `Music downloaded successfully to: ${path.join(outputPath, musicName)}` + ) + break + } + + default: + throw new Error(`Unsupported media type: ${result.type}`) + } +} + +export { getDefaultDownloadPath, downloadMedia, handleMediaDownload }