diff --git a/.gitignore b/.gitignore index 7abc476..65872b4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ lib test.js bun.lockb tsconfig.tsbuildinfo -cookies.json \ No newline at end of file +cookies.json +dist \ No newline at end of file diff --git a/README.md b/README.md index d017ec2..98b98e1 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ - [Tiktok Video Comments](#tiktok-video-comments-1) - [Tiktok User Posts](#tiktok-user-posts) - [Tiktok User Liked Videos](#tiktok-user-liked-videos) + - [Tiktok Collection](#tiktok-collection) - [Contributing](#contributing) - [License](#license) @@ -260,6 +261,99 @@ Tiktok.GetUserLiked(username, { - [Tiktok User Liked Videos Response](#tiktok-user-liked-videos) +## Tiktok Collection + +Get videos from a TikTok collection (supports collection ID or URL) + +```javascript +const Tiktok = require("@tobyg74/tiktok-api-dl") + +// Using collection ID +const collectionId = "7507916135931218695" +Tiktok.Collection(collectionId, { + page: 1, // optional, default is 1 + count: 5, // optional, default is 5 + proxy: "YOUR_PROXY" // optional +}).then((result) => console.log(result)) + +// Using collection URL +const collectionUrl = "https://www.tiktok.com/@username/collection/name-id" +Tiktok.Collection(collectionUrl, { + page: 1, + count: 5, + proxy: "YOUR_PROXY" +}).then((result) => console.log(result)) +``` + +### CLI Usage + +```bash +# Using collection ID +tiktokdl collection 7507916135931218695 -n 5 + +# Using collection URL +tiktokdl collection "https://www.tiktok.com/@username/collection/name-id" -n 5 + +# With page for pagination +tiktokdl collection 7507916135931218695 -p 1 -n 5 + +# With proxy +tiktokdl collection 7507916135931218695 -n 5 -proxy "http://your-proxy-url" +``` + +### Response Type + +```typescript +interface TiktokCollectionResponse { + status: "success" | "error" + message?: string + result?: { + itemList: Array<{ + id: string + desc: string + createTime: number + author?: { + id: string + uniqueId: string + nickname: string + avatarThumb: string + avatarMedium: string + avatarLarger: string + signature: string + verified: boolean + } + statistics?: { + playCount: number + diggCount: number + shareCount: number + commentCount: number + collectCount: number + } + video?: { + id: string + height: number + width: number + duration: number + ratio: string + cover: string + originCover: string + dynamicCover: string + playAddr: string + downloadAddr: string + format: string + bitrate: number + } + textExtra?: Array<{ + hashtagName: string + hashtagId: string + type: number + }> + }> + hasMore: boolean + } +} +``` + # API Response Types ## Tiktok Downloader @@ -712,6 +806,62 @@ interface TiktokUserFavoriteVideosResponse { } ``` +## Tiktok Collection + +### Collection Response + +```typescript +interface TiktokCollectionResponse { + status: "success" | "error" + message?: string + result?: { + itemList: Array<{ + id: string + desc: string + createTime: number + author?: { + id: string + uniqueId: string + nickname: string + avatarThumb: string + avatarMedium: string + avatarLarger: string + signature: string + verified: boolean + } + statistics?: { + playCount: number + diggCount: number + shareCount: number + commentCount: number + collectCount: number + } + video?: { + id: string + height: number + width: number + duration: number + ratio: string + cover: string + originCover: string + dynamicCover: string + playAddr: string + downloadAddr: string + format: string + bitrate: number + } + textExtra?: Array<{ + hashtagName: string + hashtagId: string + type: number + }> + }> + hasMore: boolean + cursor: string + } +} +``` + # Changelog - All changes will be documented in the [CHANGELOG.md](https://github.com/TobyG74/tiktok-api-dl/blob/master/CHANGELOG.md) file. diff --git a/src/cli/index.ts b/src/cli/index.ts index e4a2858..4da69ef 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -392,4 +392,99 @@ program } }) +// Collection Command +program + .command("collection") + .description( + "Get videos from a TikTok collection (supports collection ID or URL)" + ) + .argument( + "", + "Collection ID or URL (e.g. 7507916135931218695 or https://www.tiktok.com/@username/collection/name-id)" + ) + .option("-p, --page ", "Page number", "1") + .option("--proxy ", "Proxy URL (http/https/socks)") + .option( + "-n, --count ", + "Number of items to fetch", + (val) => parseInt(val), + 5 + ) + .action(async (collectionIdOrUrl, options) => { + try { + 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, + count: options.count + }) + + 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}`) + + for (const [index, video] of itemList.entries()) { + Logger.info(`---- VIDEO ${index + 1} ----`) + Logger.result(`Video ID: ${video.id}`, chalk.green) + Logger.result(`Description: ${video.desc}`, chalk.yellow) + Logger.result( + `Author: ${video.author?.nickname || "Unknown"}`, + chalk.yellow + ) + Logger.result( + `Created: ${new Date(video.createTime * 1000).toLocaleString()}`, + chalk.yellow + ) + + if (video.statistics) { + Logger.info(`---- STATISTICS ----`) + Logger.result( + `Likes: ${video.statistics.likeCount || 0}`, + chalk.yellow + ) + Logger.result( + `Comments: ${video.statistics.commentCount || 0}`, + chalk.yellow + ) + Logger.result( + `Shares: ${video.statistics.shareCount || 0}`, + chalk.yellow + ) + Logger.result( + `Plays: ${video.statistics.playCount || 0}`, + chalk.yellow + ) + } + + if (video.video) { + Logger.info(`---- VIDEO URLs ----`) + const videoUrl = `${_tiktokurl}/@${video.author?.uniqueId || "unknown" + }/video/${video.id}` + Logger.result(`Video URL: ${videoUrl}`, chalk.blue) + } + + if (video.textExtra?.length > 0) { + Logger.info(`---- HASHTAGS ----`) + video.textExtra.forEach((tag) => { + if (tag.hashtagName) { + Logger.result(`#${tag.hashtagName}`, chalk.cyan) + } + }) + } + } + + if (hasMore) { + Logger.info("\nTo fetch more videos, use:") + Logger.info(`tiktokdl collection ${collectionIdOrUrl} -p ${parseInt(options.page) + 1}`) + } + } else { + Logger.error(`Error: ${results.message}`) + } + } catch (error) { + Logger.error(`Error: ${error.message}`) + } + }) + program.parse() diff --git a/src/constants/api.ts b/src/constants/api.ts index 81bdde4..379e1f4 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -12,6 +12,8 @@ export const _tiktokGetComments = (params: any): string => `${_tiktokurl}/api/comment/list/?${params}` export const _tiktokGetUserLiked = (params: any): string => `${_tiktokurl}/api/favorite/item_list/?${params}` +export const _tiktokGetCollection = (params: any): string => + `${_tiktokurl}/api/collection/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 af21d8b..8203b0d 100644 --- a/src/constants/params.ts +++ b/src/constants/params.ts @@ -353,4 +353,49 @@ const generateOdinId = () => { return `${prefix}${random}` } +export const _getCollectionParams = (collectionId: string, page: number = 1, count: number = 5) => { + 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: "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", + channel: "tiktok_web", + collectionId, + cookie_enabled: true, + count, + cursor: cursor.toString(), + data_collection_enabled: true, + device_id: "7002566096994190854", + device_platform: "web_pc", + focus_state: true, + from_page: "user", + history_len: 3, + is_fullscreen: false, + is_page_visible: true, + language: "en", + odinId: "7458943931621032978", + os: "windows", + priority_region: "NZ", + referer: "", + region: "NZ", + screen_height: 1440, + screen_width: 2560, + sourceType: 113, + 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 24becf0..8fe6d28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { TiktokStalkUserResponse } from "./types/get/getProfile" import { TiktokVideoCommentsResponse } from "./types/get/getComments" import { TiktokUserPostsResponse } from "./types/get/getUserPosts" import { TiktokUserFavoriteVideosResponse } from "./types/get/getUserLiked" +import { TiktokCollectionResponse } from "./types/get/getCollection" /** Services */ import { TiktokAPI } from "./utils/downloader/tiktokApi" @@ -30,6 +31,8 @@ import { getComments } from "./utils/get/getComments" import { getUserPosts } from "./utils/get/getUserPosts" import { getUserLiked } from "./utils/get/getUserLiked" import { SearchVideo } from "./utils/search/videoSearch" +import { getCollection } from "./utils/get/getCollection" +import { extractCollectionId } from "./utils/downloader/tiktokApi" /** Constants */ import { DOWNLOADER_VERSIONS, SEARCH_TYPES } from "./constants" @@ -122,6 +125,33 @@ export = { } }, + /** + * Get TikTok Collection + * @param {string} collectionIdOrUrl - Collection ID or URL (e.g. 7507916135931218695 or https://www.tiktok.com/@username/collection/name-id) + * @param {Object} options - The options for collection + * @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 + * @returns {Promise} + */ + Collection: async ( + collectionIdOrUrl: string, + options?: { + proxy?: string + page?: number + count?: number + } + ): Promise => { + const collectionId = extractCollectionId(collectionIdOrUrl) + if (!collectionId) { + return { + status: "error", + message: "Invalid collection ID or URL format" + } + } + return await getCollection(collectionId, options?.proxy, options?.page, options?.count) + }, + /** * Tiktok Search * @param {string} keyword - The query you want to search diff --git a/src/types/downloader/tiktokApi.ts b/src/types/downloader/tiktokApi.ts index c3d6ff9..cb56fec 100644 --- a/src/types/downloader/tiktokApi.ts +++ b/src/types/downloader/tiktokApi.ts @@ -12,8 +12,10 @@ export type TiktokAPIResponse = BaseContentResponse export type AuthorTiktokAPI = Author & { uid: string username: string + uniqueId: string avatarThumb: string avatarMedium: string + url: string } export type StatisticsTiktokAPI = Statistics @@ -30,3 +32,22 @@ export type ResponseParserTiktokAPI = { author?: AuthorTiktokAPI music?: MusicTiktokAPI } + +export type TiktokCollectionResponse = { + status: "success" | "error" + message?: string + result?: { + itemList: Array<{ + id: string + desc: string + createTime: number + author: AuthorTiktokAPI + statistics: StatisticsTiktokAPI + video: VideoTiktokAPI + textExtra: Array<{ + hashtagName?: string + }> + }> + hasMore: boolean + } +} diff --git a/src/types/get/getCollection.ts b/src/types/get/getCollection.ts new file mode 100644 index 0000000..fe944b5 --- /dev/null +++ b/src/types/get/getCollection.ts @@ -0,0 +1,54 @@ +import { AuthorTiktokAPI, StatisticsTiktokAPI, MusicTiktokAPI, VideoTiktokAPI } from "../downloader/tiktokApi" + +export interface CollectionItem { + id: string + desc: string + createTime: number + author: AuthorTiktokAPI + statistics: StatisticsTiktokAPI + 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 TiktokCollectionResponse { + status: "success" | "error" + message?: string + result?: { + hasMore: boolean + itemList: CollectionItem[] + extra?: { + fatal_item_ids: string[] + logid: string + now: number + } + } +} \ No newline at end of file diff --git a/src/utils/downloader/tiktokApi.ts b/src/utils/downloader/tiktokApi.ts index c4d6c1e..5bbea07 100644 --- a/src/utils/downloader/tiktokApi.ts +++ b/src/utils/downloader/tiktokApi.ts @@ -1,14 +1,15 @@ import Axios from "axios" import asyncRetry from "async-retry" -import { _tiktokvFeed, _tiktokurl } from "../../constants/api" -import { _tiktokApiParams } from "../../constants/params" +import { _tiktokvFeed, _tiktokurl, _tiktokGetCollection } from "../../constants/api" +import { _tiktokApiParams, _getCollectionParams } from "../../constants/params" import { AuthorTiktokAPI, TiktokAPIResponse, StatisticsTiktokAPI, MusicTiktokAPI, ResponseParserTiktokAPI, - VideoTiktokAPI + VideoTiktokAPI, + TiktokCollectionResponse } from "../../types/downloader/tiktokApi" import { HttpsProxyAgent } from "https-proxy-agent" import { SocksProxyAgent } from "socks-proxy-agent" @@ -19,6 +20,7 @@ const TIKTOK_URL_REGEX = /https:\/\/(?:m|www|vm|vt|lite)?\.?tiktok\.com\/((?:.*\b(?:(?:usr|v|embed|user|video|photo)\/|\?shareId=|\&item_id=)(\d+))|\w+)/ 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+)/ /** Types */ interface ProxyConfig { @@ -61,6 +63,7 @@ const parseStatistics = (content: any): StatisticsTiktokAPI => ({ const parseAuthor = (content: any): AuthorTiktokAPI => ({ uid: content.author.uid, username: content.author.unique_id, + uniqueId: content.author.unique_id, nickname: content.author.nickname, signature: content.author.signature, region: content.author.region, @@ -194,6 +197,37 @@ const createVideoResponse = ( } }) +const handleRedirect = async (url: string, proxy?: string): Promise => { + try { + const response = await Axios(url, { + method: 'HEAD', + maxRedirects: 5, + validateStatus: (status) => status >= 200 && status < 400, + ...createProxyAgent(proxy) + }) + + // Get the final URL after all redirects + const finalUrl = response.request.res.responseUrl + + // Remove query parameters + return finalUrl.split('?')[0] + } catch (error) { + console.error('Error handling redirect:', error) + return url + } +} + +export const extractCollectionId = (input: string): string | null => { + // If it's already just a number, return it + if (/^\d+$/.test(input)) { + return input + } + + // Try to extract from URL + const match = input.match(COLLECTION_URL_REGEX) + return match ? match[1] : null +} + /** * Tiktok API Downloader * @param {string} url - Tiktok URL @@ -264,3 +298,67 @@ export const TiktokAPI = async ( } } } + +export const Collection = async ( + collectionIdOrUrl: string, + options?: { + page?: number, + proxy?: string, + count?: number + } +): Promise => { + try { + // Only handle redirects if the input is a URL + const processedUrl = collectionIdOrUrl.startsWith('http') + ? await handleRedirect(collectionIdOrUrl, options?.proxy) + : collectionIdOrUrl + + const collectionId = extractCollectionId(processedUrl) + if (!collectionId) { + return { + status: "error", + message: "Invalid collection ID or URL format" + } + } + + const response = await Axios( + _tiktokGetCollection( + _getCollectionParams(collectionId, options.page, options.count) + ), + { + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + 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 + } + } + } + + 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/getCollection.ts b/src/utils/get/getCollection.ts new file mode 100644 index 0000000..6fe7858 --- /dev/null +++ b/src/utils/get/getCollection.ts @@ -0,0 +1,92 @@ +import Axios from "axios" +import { _tiktokGetCollection } from "../../constants/api" +import { _getCollectionParams } from "../../constants/params" +import { HttpsProxyAgent } from "https-proxy-agent" +import { SocksProxyAgent } from "socks-proxy-agent" +import { TiktokCollectionResponse } from "../../types/get/getCollection" +import { ERROR_MESSAGES } from "../../constants" +import retry from "async-retry" + +/** 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 getCollection = async ( + collectionId: string, + proxy?: string, + page: number = 1, + count: number = 5 +): Promise => { + try { + const response = await retry( + async () => { + const res = await Axios( + _tiktokGetCollection( + _getCollectionParams(collectionId, page, count) + ), + { + method: "GET", + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.7", + Referer: "https://www.tiktok.com/", + Origin: "https://www.tiktok.com" + }, + ...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 + } + } +} diff --git a/test/collection-test.ts b/test/collection-test.ts new file mode 100644 index 0000000..bc8f832 --- /dev/null +++ b/test/collection-test.ts @@ -0,0 +1,70 @@ +import { Collection } from "../src/utils/downloader/tiktokApi" + +async function testCollection() { + try { + // You can use either a collection ID or URL + const collectionId = "7507916135931218695" + const collectionUrl = "https://www.tiktok.com/@getrex.co.nz/collection/big%20back-7507916135931218695" + const collectionShareableLink = "https://vt.tiktok.com/ZShvmqNjQ/" + + console.log("Testing Collection method...") + const result = await Collection(collectionId, { + page: 1, + count: 5, // Optional: Number of items to fetch + proxy: undefined // Optional: Add your proxy if needed + }) + + if (result.status === "success" && result.result) { + console.log("\nCollection fetched successfully!") + console.log("========================") + console.log("Collection Overview:") + console.log("========================") + console.log(`Total items fetched: ${result.result.itemList.length}`) + console.log(`Has more items: ${result.result.hasMore}`) + + // Log all items + result.result.itemList.forEach((item, index) => { + console.log(`\nItem ${index + 1}:`) + console.log("-------------------") + console.log(`ID: ${item.id}`) + console.log(`Description: ${item.desc}`) + console.log(`Author: ${item.author.nickname}`) + console.log(`Created: ${new Date(item.createTime * 1000).toLocaleString()}`) + + // Log video URL + if (item.video?.playAddr?.[0]) { + console.log(`Video URL: ${item.video.playAddr[0]}`) + } else { + console.log("No video URL available") + } + + // Log item statistics + if (item.statistics) { + console.log("\nStatistics:") + console.log(`- Likes: ${item.statistics.likeCount || 0}`) + console.log(`- Comments: ${item.statistics.commentCount || 0}`) + console.log(`- Shares: ${item.statistics.shareCount || 0}`) + console.log(`- Plays: ${item.statistics.playCount || 0}`) + } + + // Log hashtags if available + if (item.textExtra?.length > 0) { + console.log("\nHashtags:") + item.textExtra.forEach(tag => { + if (tag.hashtagName) { + console.log(`- #${tag.hashtagName}`) + } + }) + } + console.log("========================") + }) + } else { + console.error("Error:", result.message) + } + } catch (error) { + console.error("Test failed:", error) + } +} + +// Run the test +testCollection() \ No newline at end of file