feat: added playlist parsing support

This commit is contained in:
PRAS 2025-06-03 02:37:34 +06:00
parent 5a4143e283
commit 2fa9e6fef4
8 changed files with 479 additions and 34 deletions

View File

@ -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(
"<url>",
"Collection URL (e.g. https://www.tiktok.com/@username/playlist/name-id)"
)
.option("-p, --page <number>", "Page number", "1")
.option("--proxy <proxy>", "Proxy URL (http/https/socks)")
.option(
"-c, --count <number>",
"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}`)

View File

@ -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`

View File

@ -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 }

View File

@ -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<TiktokPlaylistResponse>}
*/
Playlist: async (
url: string,
options?: {
proxy?: string
page?: number
count?: number
}
): Promise<TiktokPlaylistResponse> => {
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
)
}
}

View File

@ -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

View File

@ -0,0 +1,73 @@
import {
AuthorTiktokAPI,
MusicTiktokAPI,
VideoTiktokAPI
} from "../downloader/tiktokApi"
export interface PlaylistAuthor
extends Omit<AuthorTiktokAPI, "username" | "uid"> {
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
}
}
}

View File

@ -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<string> => {
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<string> => {
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,14 +318,14 @@ export const TiktokAPI = async (
export const Collection = async (
collectionIdOrUrl: string,
options?: {
page?: number,
proxy?: string,
page?: number
proxy?: string
count?: number
}
): Promise<TiktokCollectionResponse> => {
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
@ -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<TiktokPlaylistResponse> => {
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
}
}
}

View File

@ -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<string> | 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<TiktokPlaylistResponse>}
*/
export const getPlaylist = async (
playlistId: string,
proxy?: string,
page: number = 1,
count: number = 5
): Promise<TiktokPlaylistResponse> => {
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
}
}
}