diff --git a/src/cli/index.ts b/src/cli/index.ts index 4da69ef..7528155 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,9 @@ import { handleMediaDownload } from "../services/downloadManager" import { _tiktokurl } from "../constants/api" +import path from "path" +import * as fs from "fs" +import axios from "axios" const cookieManager = new CookieManager() @@ -412,7 +415,9 @@ program ) .action(async (collectionIdOrUrl, options) => { try { - Logger.info(`Fetching page ${options.page} with ${options.count} items per page from collection...`) + Logger.info( + `Fetching page ${options.page} with ${options.count} items per page from collection...` + ) const results = await Tiktok.Collection(collectionIdOrUrl, { page: options.page, proxy: options.proxy, @@ -421,7 +426,6 @@ program if (results.status === "success" && results.result) { const { itemList, hasMore } = results.result - Logger.info(`Found ${itemList.length} videos in collection`) Logger.info(`Has more videos: ${hasMore}`) @@ -460,8 +464,9 @@ program if (video.video) { Logger.info(`---- VIDEO URLs ----`) - const videoUrl = `${_tiktokurl}/@${video.author?.uniqueId || "unknown" - }/video/${video.id}` + const videoUrl = `${_tiktokurl}/@${ + video.author?.uniqueId || "unknown" + }/video/${video.id}` Logger.result(`Video URL: ${videoUrl}`, chalk.blue) } @@ -477,7 +482,105 @@ program if (hasMore) { Logger.info("\nTo fetch more videos, use:") - Logger.info(`tiktokdl collection ${collectionIdOrUrl} -p ${parseInt(options.page) + 1}`) + Logger.info( + `tiktokdl collection ${collectionIdOrUrl} -p ${ + parseInt(options.page) + 1 + }` + ) + } + } else { + Logger.error(`Error: ${results.message}`) + } + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + +// ============================================= +// Playlist parser +// ============================================= +program + .command("playlist") + .description("Get videos from a TikTok playlist") + .argument( + "", + "Collection URL (e.g. https://www.tiktok.com/@username/playlist/name-id)" + ) + .option("-p, --page ", "Page number", "1") + .option("--proxy ", "Proxy URL (http/https/socks)") + .option( + "-c, --count ", + "Number of items to fetch (max: 20)", + (val) => parseInt(val), + 5 + ) + .option("-r, --raw", "Show raw response", false) + .action(async (url, options) => { + try { + Logger.info( + `Fetching page ${options.page} with ${options.count} items per page from playlist...` + ) + const results = await Tiktok.Playlist(url, { + page: options.page, + proxy: options.proxy, + count: options.count + }) + + const contentType = (content: any): string => { + if (content?.imagePost) { + return "photo" + } else { + return "video" + } + } + + if (results.status === "success" && results.result) { + if (options.raw) { + console.log(JSON.stringify(results.result, null, 2)) + return + } + const { itemList, hasMore } = results.result + + Logger.info(`Found ${itemList.length} items in playlist`) + Logger.info(`Has more items: ${hasMore}`) + + for (const [index, item] of itemList.entries()) { + Logger.info(`---- ITEM ${index + 1} ----`) + Logger.result(`Item ID: ${item.id}`, chalk.green) + Logger.result(`Description: ${item.desc}`, chalk.yellow) + Logger.result( + `Author: ${item.author?.nickname || "Unknown"}`, + chalk.yellow + ) + Logger.result( + `Created: ${new Date(item.createTime * 1000).toLocaleString()}`, + chalk.yellow + ) + + if (item.stats) { + Logger.info(`---- STATISTICS ----`) + Logger.result( + `Comments: ${item.stats.commentCount || 0}`, + chalk.yellow + ) + Logger.result(`Shares: ${item.stats.shareCount || 0}`, chalk.yellow) + Logger.result(`Plays: ${item.stats.playCount || 0}`, chalk.yellow) + } + + if (item.video) { + Logger.info(`---- VIDEO URLs ----`) + const videoUrl = `${_tiktokurl}/@${ + item.author?.uniqueId || "unknown" + }/${contentType(item)}/${item.id}` + Logger.result(`Video URL: ${videoUrl}`, chalk.blue) + } + } + + if (hasMore) { + Logger.info("\nTo fetch more videos, use:") + Logger.info( + `tiktokdl playlist ${url} -p ${parseInt(options.page) + 1}` + ) } } else { Logger.error(`Error: ${results.message}`) diff --git a/src/constants/api.ts b/src/constants/api.ts index 379e1f4..9fc3d10 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -14,6 +14,8 @@ export const _tiktokGetUserLiked = (params: any): string => `${_tiktokurl}/api/favorite/item_list/?${params}` export const _tiktokGetCollection = (params: any): string => `${_tiktokurl}/api/collection/item_list/?${params}` +export const _tiktokGetPlaylist = (params: any): string => + `${_tiktokurl}/api/mix/item_list/?${params}` /** Tiktokv */ export const _tiktokvApi: string = `https://api16-normal-useast5.tiktokv.us` diff --git a/src/constants/params.ts b/src/constants/params.ts index 8203b0d..35f0174 100644 --- a/src/constants/params.ts +++ b/src/constants/params.ts @@ -353,7 +353,11 @@ const generateOdinId = () => { return `${prefix}${random}` } -export const _getCollectionParams = (collectionId: string, page: number = 1, count: number = 5) => { +export const _getCollectionParams = ( + collectionId: string, + page: number = 1, + count: number = 5 +) => { let cursor = 0 if (page > 0) { cursor = (page - 1) * count @@ -368,7 +372,8 @@ export const _getCollectionParams = (collectionId: string, page: number = 1, cou browser_name: "Mozilla", browser_online: true, browser_platform: "Win32", - browser_version: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + browser_version: + "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", channel: "tiktok_web", collectionId, cookie_enabled: true, @@ -398,4 +403,58 @@ export const _getCollectionParams = (collectionId: string, page: number = 1, cou }) } +export const _getPlaylistParams = ( + playlistId: string, + page: number = 1, + /** + * @max 20 + * @default 5 + */ + count: number = 5 +) => { + count = Math.min(Math.max(1, count), 20) + + let cursor = 0 + if (page > 0) { + cursor = (page - 1) * count + } + + return qs.stringify({ + WebIdLastTime: Date.now(), + aid: 1988, + app_language: "en", + app_name: "tiktok_web", + browser_language: "en-US", + browser_name: "Mozilla", + browser_online: true, + browser_platform: "Linux x86_64", + browser_version: "5.0 (X11)", + channel: "tiktok_web", + cookie_enabled: true, + count, + cursor: cursor.toString(), + data_collection_enabled: true, + device_id: generateDeviceId(), + device_platform: "web_pc", + focus_state: true, + from_page: "user", + history_len: 1, + is_fullscreen: false, + is_page_visible: true, + language: "en", + mixId: playlistId, + odinId: generateOdinId(), + os: "linux", + priority_region: "NZ", + referer: "", + region: "NZ", + screen_height: 1440, + screen_width: 2560, + tz_name: "Pacific/Auckland", + user_is_login: true, + verifyFp: "verify_lacphy8d_z2ux9idt_xdmu_4gKb_9nng_NNTTTvsFS8ao", + webcast_language: "en" + }) +} + export { randomChar, generateSearchId, generateDeviceId, generateOdinId } diff --git a/src/index.ts b/src/index.ts index 8fe6d28..aa8c1c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,9 @@ import { TiktokAPIResponse } from "./types/downloader/tiktokApi" import { SSSTikResponse } from "./types/downloader/ssstik" import { MusicalDownResponse } from "./types/downloader/musicaldown" -import { - TiktokUserSearchResponse, - UserSearchResult -} from "./types/search/userSearch" -import { - TiktokLiveSearchResponse, - LiveSearchResult -} from "./types/search/liveSearch" -import { - TiktokVideoSearchResponse, - VideoSearchResult -} from "./types/search/videoSearch" +import { UserSearchResult } from "./types/search/userSearch" +import { LiveSearchResult } from "./types/search/liveSearch" +import { VideoSearchResult } from "./types/search/videoSearch" import { TiktokStalkUserResponse } from "./types/get/getProfile" import { TiktokVideoCommentsResponse } from "./types/get/getComments" import { TiktokUserPostsResponse } from "./types/get/getUserPosts" @@ -21,7 +12,7 @@ import { TiktokUserFavoriteVideosResponse } from "./types/get/getUserLiked" import { TiktokCollectionResponse } from "./types/get/getCollection" /** Services */ -import { TiktokAPI } from "./utils/downloader/tiktokApi" +import { extractPlaylistId, TiktokAPI } from "./utils/downloader/tiktokApi" import { SSSTik } from "./utils/downloader/ssstik" import { MusicalDown } from "./utils/downloader/musicalDown" import { StalkUser } from "./utils/get/getProfile" @@ -38,6 +29,8 @@ import { extractCollectionId } from "./utils/downloader/tiktokApi" import { DOWNLOADER_VERSIONS, SEARCH_TYPES } from "./constants" import { ERROR_MESSAGES } from "./constants" import { validateCookie } from "./utils/validator" +import { TiktokPlaylistResponse } from "./types/get/getPlaylist" +import { getPlaylist } from "./utils/get/getPlaylist" /** Types */ type DownloaderVersion = "v1" | "v2" | "v3" @@ -149,7 +142,12 @@ export = { message: "Invalid collection ID or URL format" } } - return await getCollection(collectionId, options?.proxy, options?.page, options?.count) + return await getCollection( + collectionId, + options?.proxy, + options?.page, + options?.count + ) }, /** @@ -314,5 +312,37 @@ export = { options?.proxy, options?.postLimit ) + }, + + /** + * Get TikTok Playlist + * @param {string} url - URL (e.g. https://www.tiktok.com/@username/playlist/name-id) + * @param {Object} options - The options for playlist + * @param {string} [options.proxy] - Optional proxy URL + * @param {string} [options.page] - Optional page for pagination + * @param {number} [options.count] - Optional number of items to fetch(max: 20) + * @returns {Promise} + */ + Playlist: async ( + url: string, + options?: { + proxy?: string + page?: number + count?: number + } + ): Promise => { + const playlistId = extractPlaylistId(url) + if (!playlistId) { + return { + status: "error", + message: "Invalid playlist ID or URL format" + } + } + return await getPlaylist( + playlistId, + options?.proxy, + options?.page, + options?.count + ) } } diff --git a/src/types/get/getCollection.ts b/src/types/get/getCollection.ts index fe944b5..56fa278 100644 --- a/src/types/get/getCollection.ts +++ b/src/types/get/getCollection.ts @@ -1,10 +1,15 @@ -import { AuthorTiktokAPI, StatisticsTiktokAPI, MusicTiktokAPI, VideoTiktokAPI } from "../downloader/tiktokApi" +import { + StatisticsTiktokAPI, + MusicTiktokAPI, + VideoTiktokAPI +} from "../downloader/tiktokApi" +import { PlaylistAuthor } from "./getPlaylist" export interface CollectionItem { id: string desc: string createTime: number - author: AuthorTiktokAPI + author: PlaylistAuthor statistics: StatisticsTiktokAPI video: VideoTiktokAPI music: MusicTiktokAPI @@ -51,4 +56,4 @@ export interface TiktokCollectionResponse { now: number } } -} \ No newline at end of file +} diff --git a/src/types/get/getPlaylist.ts b/src/types/get/getPlaylist.ts new file mode 100644 index 0000000..dae94ab --- /dev/null +++ b/src/types/get/getPlaylist.ts @@ -0,0 +1,73 @@ +import { + AuthorTiktokAPI, + MusicTiktokAPI, + VideoTiktokAPI +} from "../downloader/tiktokApi" + +export interface PlaylistAuthor + extends Omit { + avatarLarger: string + nickname: string + id: string +} + +interface Statistics { + collectCount: number + commentCount: number + diggCount: number + playCount: number + shareCount: number +} + +export interface PlaylistItem { + id: string + desc: string + createTime: number + author: PlaylistAuthor + stats: Statistics + video: VideoTiktokAPI + music: MusicTiktokAPI + challenges: Array<{ + id: string + title: string + desc: string + coverLarger: string + coverMedium: string + coverThumb: string + profileLarger: string + profileMedium: string + profileThumb: string + }> + collected: boolean + digged: boolean + duetDisplay: number + forFriend: boolean + officalItem: boolean + originalItem: boolean + privateItem: boolean + shareEnabled: boolean + stitchDisplay: number + textExtra: Array<{ + awemeId: string + end: number + hashtagName: string + isCommerce: boolean + start: number + subType: number + type: number + }> +} + +export interface TiktokPlaylistResponse { + status: "success" | "error" + message?: string + result?: { + hasMore: boolean + itemList: PlaylistItem[] + extra?: { + fatal_item_ids: string[] + logid: string + now: number + } + } +} diff --git a/src/utils/downloader/tiktokApi.ts b/src/utils/downloader/tiktokApi.ts index 5bbea07..4a9f90b 100644 --- a/src/utils/downloader/tiktokApi.ts +++ b/src/utils/downloader/tiktokApi.ts @@ -1,7 +1,16 @@ import Axios from "axios" import asyncRetry from "async-retry" -import { _tiktokvFeed, _tiktokurl, _tiktokGetCollection } from "../../constants/api" -import { _tiktokApiParams, _getCollectionParams } from "../../constants/params" +import { + _tiktokvFeed, + _tiktokurl, + _tiktokGetCollection, + _tiktokGetPlaylist +} from "../../constants/api" +import { + _tiktokApiParams, + _getCollectionParams, + _getPlaylistParams +} from "../../constants/params" import { AuthorTiktokAPI, TiktokAPIResponse, @@ -14,6 +23,7 @@ import { import { HttpsProxyAgent } from "https-proxy-agent" import { SocksProxyAgent } from "socks-proxy-agent" import { ERROR_MESSAGES } from "../../constants" +import { TiktokPlaylistResponse } from "../../types/get/getPlaylist" /** Constants */ const TIKTOK_URL_REGEX = @@ -21,6 +31,7 @@ const TIKTOK_URL_REGEX = const USER_AGENT = "com.zhiliaoapp.musically/300904 (2018111632; U; Android 10; en_US; Pixel 4; Build/QQ3A.200805.001; Cronet/58.0.2991.0)" const COLLECTION_URL_REGEX = /collection\/[^/]+-(\d+)/ +const PLAYLIST_URL_REGEX = /playlist\/[^/]+-(\d+)/ /** Types */ interface ProxyConfig { @@ -200,7 +211,7 @@ const createVideoResponse = ( const handleRedirect = async (url: string, proxy?: string): Promise => { try { const response = await Axios(url, { - method: 'HEAD', + method: "HEAD", maxRedirects: 5, validateStatus: (status) => status >= 200 && status < 400, ...createProxyAgent(proxy) @@ -210,9 +221,9 @@ const handleRedirect = async (url: string, proxy?: string): Promise => { const finalUrl = response.request.res.responseUrl // Remove query parameters - return finalUrl.split('?')[0] + return finalUrl.split("?")[0] } catch (error) { - console.error('Error handling redirect:', error) + console.error("Error handling redirect:", error) return url } } @@ -228,6 +239,11 @@ export const extractCollectionId = (input: string): string | null => { return match ? match[1] : null } +export const extractPlaylistId = (url: string): string | null => { + const match = url.match(PLAYLIST_URL_REGEX) + return match ? match[1] : null +} + /** * Tiktok API Downloader * @param {string} url - Tiktok URL @@ -302,17 +318,17 @@ export const TiktokAPI = async ( export const Collection = async ( collectionIdOrUrl: string, options?: { - page?: number, - proxy?: string, + page?: number + proxy?: string count?: number } ): Promise => { try { // Only handle redirects if the input is a URL - const processedUrl = collectionIdOrUrl.startsWith('http') + const processedUrl = collectionIdOrUrl.startsWith("http") ? await handleRedirect(collectionIdOrUrl, options?.proxy) : collectionIdOrUrl - + const collectionId = extractCollectionId(processedUrl) if (!collectionId) { return { @@ -358,7 +374,73 @@ export const Collection = async ( } catch (error) { return { status: "error", - message: error instanceof Error ? error.message : ERROR_MESSAGES.NETWORK_ERROR + message: + error instanceof Error ? error.message : ERROR_MESSAGES.NETWORK_ERROR + } + } +} + +export const Playlist = async ( + url: string, + options?: { + page?: number + proxy?: string + count?: number + } +): Promise => { + try { + const processedUrl = url.startsWith("http") + ? await handleRedirect(url, options?.proxy) + : url + + const playlistId = extractPlaylistId(processedUrl) + if (!playlistId) { + return { + status: "error", + message: "Invalid playlist ID or URL format" + } + } + + const response = await Axios( + _tiktokGetPlaylist( + _getPlaylistParams(playlistId, options.page, options.count) + ), + { + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0", + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.7", + Referer: "https://www.tiktok.com/", + Origin: "https://www.tiktok.com" + }, + ...createProxyAgent(options?.proxy) + } + ) + + if (response.data && response.data.status_code === 0) { + const data = response.data + + return { + status: "success", + result: { + itemList: data.itemList || [], + hasMore: data.hasMore, + extra: data.extra + } + } + } + + return { + status: "error", + message: ERROR_MESSAGES.NETWORK_ERROR + } + } catch (error) { + return { + status: "error", + message: + error instanceof Error ? error.message : ERROR_MESSAGES.NETWORK_ERROR } } } diff --git a/src/utils/get/getPlaylist.ts b/src/utils/get/getPlaylist.ts new file mode 100644 index 0000000..ee0354e --- /dev/null +++ b/src/utils/get/getPlaylist.ts @@ -0,0 +1,91 @@ +import Axios from "axios" +import { _tiktokGetPlaylist, _tiktokurl } from "../../constants/api" +import { _getPlaylistParams } from "../../constants/params" +import { HttpsProxyAgent } from "https-proxy-agent" +import { SocksProxyAgent } from "socks-proxy-agent" +import { ERROR_MESSAGES } from "../../constants" +import retry from "async-retry" +import { TiktokPlaylistResponse } from "../../types/get/getPlaylist" + +/** Types */ +interface ProxyConfig { + httpsAgent?: HttpsProxyAgent | SocksProxyAgent +} + +const createProxyAgent = (proxy?: string): ProxyConfig => { + if (!proxy) return {} + + if (proxy.startsWith("socks")) { + return { + httpsAgent: new SocksProxyAgent(proxy) + } + } + + return { + httpsAgent: new HttpsProxyAgent(proxy) + } +} + +/** + * Get TikTok Collection + * @param {string} collectionId - Collection ID + * @param {string} proxy - Your Proxy (optional) + * @param {string} page - Page for pagination (optional) + * @param {number} count - Number of items to fetch (optional) + * @returns {Promise} + */ +export const getPlaylist = async ( + playlistId: string, + proxy?: string, + page: number = 1, + count: number = 5 +): Promise => { + try { + const response = await retry( + async () => { + const res = await Axios( + _tiktokGetPlaylist(_getPlaylistParams(playlistId, page, count)), + { + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0", + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.7", + Referer: "https://www.tiktok.com/", + Origin: "https://www.tiktok.com", + "Content-Type": "application/json", + }, + ...createProxyAgent(proxy) + } + ) + + if (res.data && res.data.statusCode === 0) { + return res.data + } + + throw new Error(ERROR_MESSAGES.NETWORK_ERROR) + }, + { + retries: 20, + minTimeout: 200, + maxTimeout: 1000 + } + ) + + return { + status: "success", + result: { + hasMore: response.hasMore, + itemList: response.itemList || [], + extra: response.extra, + } + } + } catch (error) { + return { + status: "error", + message: + error instanceof Error ? error.message : ERROR_MESSAGES.NETWORK_ERROR + } + } +}