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/src/cli/index.ts b/src/cli/index.ts index e4a2858..580d333 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -392,4 +392,94 @@ 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("-c, --cursor ", "Cursor for pagination", "0") + .option("-p, --proxy ", "Proxy URL (http/https/socks)") + .action(async (collectionIdOrUrl, options) => { + try { + Logger.info(`Fetching collection...`) + const results = await Tiktok.Collection(collectionIdOrUrl, { + cursor: options.cursor, + proxy: options.proxy + }) + + if (results.status === "success" && results.result) { + const { itemList, hasMore, cursor } = results.result + + Logger.info(`Found ${itemList.length} videos in collection`) + Logger.info(`Has more videos: ${hasMore}`) + Logger.info(`Next cursor: ${cursor}\n`) + + 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} -c ${cursor}`) + } + } 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..9fff962 100644 --- a/src/constants/params.ts +++ b/src/constants/params.ts @@ -353,4 +353,45 @@ const generateOdinId = () => { return `${prefix}${random}` } +export const _getCollectionParams = (collectionId: string, cursor: string = "0") => { + return qs.stringify({ + WebIdLastTime: 1741246176, + 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", + clientABVersions: "70508271,72437276,73356773,73406215,73464037,73558921,73561312,73563784,73607175,73713381,73720541,73737112,73759867,73768252,73787023,73810364,73810951,73811265,73812969,73815488,73815490,73817289,73821742,73849114,73855857,73858886,73858985,73867894,73880997,73902810,70138197,70156809,70405643,71057832,71200802,71381811,71516509,71803300,71962127,72360691,72408100,72854054,72892778,73004916,73171280,73208420,73574728,73628214", + collectionId, + cookie_enabled: true, + count: 30, + cursor, + data_collection_enabled: true, + device_id: "7478595310673266194", + 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_mb1zbd2f_sMPZ5W5a_A3yc_4dmk_8NT3_kp4HJQOdrhp5", + webcast_language: "en" + }) +} + export { randomChar, generateSearchId, generateDeviceId, generateOdinId } diff --git a/src/index.ts b/src/index.ts index 24becf0..5f574f2 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,31 @@ 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.cursor] - Optional cursor for pagination + * @returns {Promise} + */ + Collection: async ( + collectionIdOrUrl: string, + options?: { + proxy?: string + cursor?: string + } + ): Promise => { + const collectionId = extractCollectionId(collectionIdOrUrl) + if (!collectionId) { + return { + status: "error", + message: "Invalid collection ID or URL format" + } + } + return await getCollection(collectionId, options?.proxy, options?.cursor) + }, + /** * 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..f096f04 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,23 @@ 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 + cursor: string + } +} diff --git a/src/types/get/getCollection.ts b/src/types/get/getCollection.ts new file mode 100644 index 0000000..65ad7d6 --- /dev/null +++ b/src/types/get/getCollection.ts @@ -0,0 +1,55 @@ +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?: { + cursor: string + 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..01d8cf2 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 { _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,17 @@ const createVideoResponse = ( } }) +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 +278,85 @@ export const TiktokAPI = async ( } } } + +export const Collection = async ( + collectionIdOrUrl: string, + options?: { + cursor?: string + proxy?: string + } +): Promise => { + try { + const collectionId = extractCollectionId(collectionIdOrUrl) + if (!collectionId) { + return { + status: "error", + message: "Invalid collection ID or URL format" + } + } + + const response = await Axios( + _tiktokvFeed(_getCollectionParams(collectionId, options?.cursor)), + { + method: "OPTIONS", + headers: { "User-Agent": USER_AGENT }, + ...createProxyAgent(options?.proxy) + } + ) + + if (response.data && response.data.status_code === 0) { + const data = response.data + const itemList = data.aweme_list.map((item: any) => ({ + id: item.aweme_id, + desc: item.desc, + createTime: item.create_time, + author: { + uid: item.author.uid, + username: item.author.unique_id, + uniqueId: item.author.unique_id, + nickname: item.author.nickname, + signature: item.author.signature, + region: item.author.region, + avatarThumb: item.author?.avatar_thumb?.url_list || [], + avatarMedium: item.author?.avatar_medium?.url_list || [], + url: `${_tiktokurl}/@${item.author.unique_id}` + }, + statistics: { + likeCount: item.statistics.digg_count, + commentCount: item.statistics.comment_count, + shareCount: item.statistics.share_count, + playCount: item.statistics.play_count + }, + video: { + ratio: item.video.ratio, + duration: item.video.duration, + playAddr: item.video?.play_addr?.url_list || [], + downloadAddr: item.video?.download_addr?.url_list || [], + cover: item.video?.cover?.url_list || [], + dynamicCover: item.video?.dynamic_cover?.url_list || [], + originCover: item.video?.origin_cover?.url_list || [] + }, + textExtra: item.text_extra || [] + })) + + return { + status: "success", + result: { + itemList, + hasMore: data.has_more, + cursor: data.cursor + } + } + } + + 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..32cfbc9 --- /dev/null +++ b/src/utils/get/getCollection.ts @@ -0,0 +1,84 @@ +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} cursor - Cursor for pagination (optional) + * @returns {Promise} + */ +export const getCollection = async ( + collectionId: string, + proxy?: string, + cursor: string = "0" +): Promise => { + try { + const response = await retry( + async () => { + const res = await Axios(_tiktokGetCollection(_getCollectionParams(collectionId, cursor)), { + 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: { + cursor: response.cursor, + hasMore: response.hasMore, + itemList: response.itemList, + extra: response.extra + } + } + } catch (error) { + return { + status: "error", + message: error instanceof Error ? error.message : ERROR_MESSAGES.NETWORK_ERROR + } + } +} \ No newline at end of file diff --git a/test/collection.ts b/test/collection.ts new file mode 100644 index 0000000..63b7e14 --- /dev/null +++ b/test/collection.ts @@ -0,0 +1,85 @@ +import Tiktok from "../src" + +async function testCollection() { + try { + // Test collection ID from your example + const collectionId = "7507916135931218695" + + console.log("Fetching collection...") + const collection = await Tiktok.Collection(collectionId, { + cursor: "0" // Optional: For pagination + }) + + console.log(collection) + + if (collection.status === "success" && collection.result) { + const { itemList, hasMore, cursor } = collection.result + + console.log(`\nFound ${itemList.length} videos in collection`) + console.log(`Has more videos: ${hasMore}`) + console.log(`Next cursor: ${cursor}\n`) + + // Print details of first video + if (itemList.length > 0) { + const firstVideo = itemList[0] + console.log("First video details:") + console.log("-------------------") + console.log(`Description: ${firstVideo.desc}`) + console.log(`Author: ${firstVideo.author?.nickname || 'Unknown'}`) + console.log( + `Created: ${new Date(firstVideo.createTime * 1000).toLocaleString()}` + ) + + // Print statistics if available + if (firstVideo.statistics) { + console.log("\nStatistics:") + console.log(`- Likes: ${firstVideo.statistics.likeCount || 0}`) + console.log(`- Comments: ${firstVideo.statistics.commentCount || 0}`) + console.log(`- Shares: ${firstVideo.statistics.shareCount || 0}`) + console.log(`- Plays: ${firstVideo.statistics.playCount || 0}`) + } + + // Print video URLs if available + if (firstVideo.video) { + console.log("\nVideo URLs:") + if (firstVideo.video.playAddr?.[0]) { + console.log(`- Play URL: ${firstVideo.video.playAddr[0]}`) + } + if (firstVideo.video.downloadAddr?.[0]) { + console.log(`- Download URL: ${firstVideo.video.downloadAddr[0]}`) + } + } + + // Print hashtags if available + if (firstVideo.textExtra?.length > 0) { + console.log("\nHashtags:") + firstVideo.textExtra.forEach((tag) => { + if (tag.hashtagName) { + console.log(`- #${tag.hashtagName}`) + } + }) + } + } + + // If there are more videos, you can fetch the next page + if (hasMore) { + console.log("\nFetching next page...") + const nextPage = await Tiktok.Collection(collectionId, { + proxy: "http://your-proxy-url", // Optional: Add your proxy if needed + cursor: cursor + }) + + if (nextPage.status === "success" && nextPage.result) { + console.log(`Found ${nextPage.result.itemList.length} more videos`) + } + } + } else { + console.error("Error:", collection.message) + } + } catch (error) { + console.error("Test failed:", error) + } +} + +// Run the test +testCollection()