From 9bab4ad652058cf3cd9c7cc79a94d12a3606c200 Mon Sep 17 00:00:00 2001 From: Tobi Saputra Date: Sat, 12 Jul 2025 18:05:26 +0700 Subject: [PATCH] feat: add get user repost videos --- src/cli/index.ts | 51 +++--- src/constants/api.ts | 2 + src/index.ts | 29 +++- src/types/get/getUserReposts.ts | 112 ++++++++++++ src/utils/get/getUserLiked.ts | 2 - src/utils/get/getUserRepost.ts | 291 ++++++++++++++++++++++++++++++++ test/userreposts-test.ts | 57 +++++++ 7 files changed, 519 insertions(+), 25 deletions(-) create mode 100644 src/types/get/getUserReposts.ts create mode 100644 src/utils/get/getUserRepost.ts create mode 100644 test/userreposts-test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 3e33181..ff9381b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -400,42 +400,49 @@ program } }) -// Get User Favorites Command +// Get User Reposts Command program - .command("getuserliked") - .description("Get user liked videos from a TikTok user") + .command("getuserreposts") + .description("Get reposts from a TikTok user") .argument("", "TikTok username") - .option("-l, --limit ", "Limit of posts", "5") + .option("-l, --limit ", "Limit of reposts", "5") .option("--proxy ", "Proxy URL (http/https/socks)") .action(async (username, options) => { try { const postLimit = parseInt(options.limit) - const results = await Tiktok.GetUserLiked(username, { - cookie: cookieManager.getCookie(), + const results = await Tiktok.GetUserReposts(username, { postLimit: postLimit, proxy: options.proxy }) if (results.status === "success") { const data = results.result - for (const [index, liked] of data.entries()) { - Logger.info(`---- FAVORITE ${index + 1} ----`) - Logger.result(`Video ID: ${liked.id}`, chalk.green) - Logger.result(`Description: ${liked.desc}`, chalk.yellow) - Logger.result(`Author: ${liked.author.nickname}`, chalk.yellow) - Logger.result( - `Video URL: ${_tiktokurl}/@${liked.author.username}/video/${liked.video.id}`, - chalk.yellow - ) + for (const [index, repost] of data.entries()) { + Logger.info(`---- REPOST ${index + 1} ----`) + Logger.result(`Video ID: ${repost.id}`, chalk.green) + Logger.result(`Description: ${repost.desc}`, chalk.yellow) Logger.info(`---- STATISTICS ----`) - Logger.result(`Likes: ${liked.stats.diggCount}`, chalk.yellow) - Logger.result(`Favorites: ${liked.stats.collectCount}`, chalk.yellow) - Logger.result(`Views: ${liked.stats.playCount}`, chalk.yellow) - Logger.result(`Shares: ${liked.stats.shareCount}`, chalk.yellow) - Logger.result(`Comments: ${liked.stats.commentCount}`, chalk.yellow) - Logger.result(`Reposts: ${liked.stats.repostCount}`, chalk.yellow) + Logger.result(`Shares: ${repost.stats.shareCount}`, chalk.yellow) + if (repost.stats.likeCount) { + Logger.result(`Likes: ${repost.stats.likeCount}`, chalk.yellow) + } + if (repost.stats.collectCount) { + Logger.result( + `Favorites: ${repost.stats.collectCount}`, + chalk.yellow + ) + } + if (repost.stats.playCount) { + Logger.result(`Views: ${repost.stats.playCount}`, chalk.yellow) + } + if (repost.stats.commentCount) { + Logger.result( + `Comments: ${repost.stats.commentCount}`, + chalk.yellow + ) + } } - Logger.info(`Total Liked Videos: ${data.length}`) + Logger.info(`Total reposts: ${data.length}`) } else { Logger.error(`Error: ${results.message}`) } diff --git a/src/constants/api.ts b/src/constants/api.ts index 9fc3d10..4348b03 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -8,6 +8,8 @@ export const _tiktokSearchLiveFull = (params: any): string => `${_tiktokurl}/api/search/live/full/?${params}` export const _tiktokGetPosts = (params: any): string => `${_tiktokurl}/api/post/item_list/?${params}` +export const _tiktokGetReposts = (params: any): string => + `${_tiktokurl}/api/repost/item_list/?${params}` export const _tiktokGetComments = (params: any): string => `${_tiktokurl}/api/comment/list/?${params}` export const _tiktokGetUserLiked = (params: any): string => diff --git a/src/index.ts b/src/index.ts index a28250c..3b1a07c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { VideoSearchResult } from "./types/search/videoSearch" import { TiktokStalkUserResponse } from "./types/get/getProfile" import { TiktokVideoCommentsResponse } from "./types/get/getComments" import { TiktokUserPostsResponse } from "./types/get/getUserPosts" +import { TiktokUserRepostsResponse } from "./types/get/getUserReposts" import { TiktokUserFavoriteVideosResponse } from "./types/get/getUserLiked" import { TiktokCollectionResponse } from "./types/get/getCollection" @@ -20,6 +21,7 @@ import { SearchUser } from "./utils/search/userSearch" import { SearchLive } from "./utils/search/liveSearch" import { getComments } from "./utils/get/getComments" import { getUserPosts } from "./utils/get/getUserPosts" +import { getUserReposts } from "./utils/get/getUserRepost" import { getUserLiked } from "./utils/get/getUserLiked" import { SearchVideo } from "./utils/search/videoSearch" import { getCollection } from "./utils/get/getCollection" @@ -236,7 +238,7 @@ export = { /** * Tiktok Get User Posts * @param {string} username - The username you want to get posts from - * @param {Object} options - The options for getting posts + * @param {Object} [options] - The options for getting posts * @param {number} [options.postLimit] - Limit number of posts to fetch * @param {string} [options.proxy] - Optional proxy URL * @returns {Promise} @@ -282,6 +284,31 @@ export = { ) }, + /** + * Tiktok Get User Reposts + * @param {string} username - The username you want to get reposts from + * @param {Object} [options] - The options for getting reposts + * @param {number} [options.postLimit] - Limit number of reposts to fetch + * @param {string} [options.proxy] - Optional proxy URL + * @param {boolean} [options.filterDeletedPost] - Whether to filter deleted posts () + * @returns {Promise} + */ + GetUserReposts: async ( + username: string, + options?: { + postLimit?: number + proxy?: string + filterDeletedPost?: boolean + } + ): Promise => { + return await getUserReposts( + username, + options?.proxy, + options?.postLimit, + options?.filterDeletedPost + ) + }, + /** * Get TikTok Collection * @param {string} collectionIdOrUrl - Collection ID or URL (e.g. 7507916135931218695 or https://www.tiktok.com/@username/collection/name-id) diff --git a/src/types/get/getUserReposts.ts b/src/types/get/getUserReposts.ts new file mode 100644 index 0000000..6a3a87b --- /dev/null +++ b/src/types/get/getUserReposts.ts @@ -0,0 +1,112 @@ +export type TiktokUserRepostsResponse = { + status: "success" | "error" + message?: string + result?: Reposts[] + totalReposts?: number +} + +export type Reposts = { + id: string + desc: string + createTime: number + digged: boolean + duetEnabled?: boolean + forFriend: boolean + officalItem: boolean + originalItem: boolean + privateItem: boolean + secret: boolean + shareEnabled: boolean + stitchEnabled?: boolean + stats: StatsRepost + author: AuthorRepost + video?: VideoRepost + music: MusicRepost + imagePost?: ImageRepost + AIGCDescription?: string + CategoryType?: number + collected?: boolean + contents?: any[] + challenges?: any[] + textExtra?: any[] + textLanguage?: string + textTranslatable?: boolean + titleLanguage?: string + titleTranslatable?: boolean + isAd?: boolean + isReviewing?: boolean + itemCommentStatus?: number + item_control?: ItemControl + duetDisplay?: number + stitchDisplay?: number + diversificationId?: number + backendSourceEventTracking?: string + stickersOnItem?: any[] + videoSuggestWordsList?: any +} + +export type StatsRepost = { + shareCount: number + collectCount?: number + commentCount?: number + likeCount?: number + playCount?: number + repostCount?: number +} + +export type AuthorRepost = { + id: string + username: string + nickname: string + avatarLarger: string + avatarThumb: string + avatarMedium: string + signature: string + verified: boolean + openFavorite?: boolean + privateAccount?: boolean + isADVirtual?: boolean + isEmbedBanned?: boolean +} + +export type VideoRepost = { + id: string + duration: number + ratio: string + cover: string + originCover: string + dynamicCover: string + playAddr: string + downloadAddr: string + format: string + bitrate: number +} + +export type MusicRepost = { + authorName?: string + coverLarge?: string + coverMedium?: string + coverThumb?: string + duration?: number + id?: string + title?: string + playUrl?: string + original?: boolean + tt2dsp?: any +} + +export type ImageRepost = { + title: string + images?: ImageRepostItem[] +} + +export type ImageRepostItem = { + imageURL: { + urlList: string[] + } +} + +export type ItemControl = { + can_repost?: boolean + can_share?: boolean +} diff --git a/src/utils/get/getUserLiked.ts b/src/utils/get/getUserLiked.ts index cb868e9..8c7d1c3 100644 --- a/src/utils/get/getUserLiked.ts +++ b/src/utils/get/getUserLiked.ts @@ -66,7 +66,6 @@ const parseUserLiked = async ( ): Promise => { // Liked Result let hasMore = true - let cursor = 0 const favorites: LikedResponse[] = [] let counter = 0 while (hasMore) { @@ -186,7 +185,6 @@ const parseUserLiked = async ( // Update hasMore and cursor for next iteration hasMore = result.hasMore - cursor = hasMore ? result.cursor : null counter++ // Check post limit if specified diff --git a/src/utils/get/getUserRepost.ts b/src/utils/get/getUserRepost.ts new file mode 100644 index 0000000..d0666ce --- /dev/null +++ b/src/utils/get/getUserRepost.ts @@ -0,0 +1,291 @@ +import Axios from "axios" +import { _tiktokGetReposts } from "../../constants/api" +import { + AuthorRepost, + Reposts, + TiktokUserRepostsResponse +} from "../../types/get/getUserReposts" +import { _getUserRepostsParams } from "../../constants/params" +import { HttpsProxyAgent } from "https-proxy-agent" +import { SocksProxyAgent } from "socks-proxy-agent" +import { StalkUser } from "../get/getProfile" +import retry from "async-retry" + +export const getUserReposts = ( + username: string, + proxy?: string, + postLimit?: number, + filterDeletedPost: boolean = true +): Promise => + new Promise((resolve) => { + try { + StalkUser(username).then(async (res) => { + if (res.status === "error") { + return resolve({ + status: "error", + message: res.message + }) + } + + const secUid = res.result.user.secUid + const data = await parseUserReposts( + secUid, + postLimit, + proxy, + filterDeletedPost + ) + + if (!data.length) + return resolve({ + status: "error", + message: "User not found!" + }) + + resolve({ + status: "success", + result: data, + totalReposts: data.length + }) + }) + } catch (err) { + if ( + err.status == 400 || + (err.response.data && err.response.data.statusCode == 10201) + ) { + return resolve({ + status: "error", + message: "Video not found!" + }) + } + } + }) + +const parseUserReposts = async ( + secUid: string, + postLimit?: number, + proxy?: string, + filterDeletedPost?: boolean +): Promise => { + // Posts Result + let page = 1 + let hasMore = true + let responseCursor = 0 + const posts: Reposts[] = [] + let counter = 0 + + while (hasMore) { + let result: any | null = null + let urlCursor = 0 + let urlCount = 0 + + if (page === 1) { + urlCount = 16 + urlCursor = 0 + } else { + urlCount = 16 + urlCursor = responseCursor + } + + // Prevent missing response posts + result = await requestUserReposts(proxy, secUid, urlCursor, urlCount) + + if (!result || !result.itemList || result.itemList.length === 0) { + hasMore = false + break + } + + // Filter out deleted posts if specified + if (filterDeletedPost) { + result.itemList = result.itemList.filter( + (item: any) => item.createTime !== 0 + ) + } + + result?.itemList?.forEach((v: any) => { + const author: AuthorRepost = { + id: v.author?.id, + username: v.author?.uniqueId, + nickname: v.author?.nickname, + avatarLarger: v.author?.avatarLarger, + avatarThumb: v.author?.avatarThumb, + avatarMedium: v.author?.avatarMedium, + signature: v.author?.signature, + verified: v.author?.verified, + openFavorite: v.author?.openFavorite, + privateAccount: v.author?.privateAccount, + isADVirtual: v.author?.isADVirtual, + isEmbedBanned: v.author?.isEmbedBanned + } + + if (v.imagePost) { + const imagePost = { + title: v.imagePost.title || "", + images: + v.imagePost.images?.map((img: any) => ({ + imageURL: { + urlList: img.imageURL?.urlList || [] + } + })) || [] + } + + posts.push({ + id: v.id, + desc: v.desc, + createTime: v.createTime, + digged: v.digged || false, + duetEnabled: v.duetEnabled, + forFriend: v.forFriend || false, + officalItem: v.officalItem || false, + originalItem: v.originalItem || false, + privateItem: v.privateItem || false, + secret: v.secret || false, + shareEnabled: v.shareEnabled || false, + stitchEnabled: v.stitchEnabled, + stats: v.stats || { shareCount: 0 }, + music: v.music || {}, + author, + imagePost, + AIGCDescription: v.AIGCDescription, + CategoryType: v.CategoryType, + collected: v.collected, + contents: v.contents || [], + challenges: v.challenges || [], + textExtra: v.textExtra || [], + textLanguage: v.textLanguage, + textTranslatable: v.textTranslatable, + titleLanguage: v.titleLanguage, + titleTranslatable: v.titleTranslatable, + isAd: v.isAd, + isReviewing: v.isReviewing, + itemCommentStatus: v.itemCommentStatus, + item_control: v.item_control, + duetDisplay: v.duetDisplay, + stitchDisplay: v.stitchDisplay, + diversificationId: v.diversificationId, + backendSourceEventTracking: v.backendSourceEventTracking, + stickersOnItem: v.stickersOnItem || [], + videoSuggestWordsList: v.videoSuggestWordsList + }) + } else { + const video = { + id: v.video?.id, + duration: v.video?.duration, + format: v.video?.format, + bitrate: v.video?.bitrate, + ratio: v.video?.ratio, + playAddr: v.video?.playAddr, + cover: v.video?.cover, + originCover: v.video?.originCover, + dynamicCover: v.video?.dynamicCover, + downloadAddr: v.video?.downloadAddr + } + + posts.push({ + id: v.id, + desc: v.desc, + createTime: v.createTime, + digged: v.digged || false, + duetEnabled: v.duetEnabled, + forFriend: v.forFriend || false, + officalItem: v.officalItem || false, + originalItem: v.originalItem || false, + privateItem: v.privateItem || false, + secret: v.secret || false, + shareEnabled: v.shareEnabled || false, + stitchEnabled: v.stitchEnabled, + stats: v.stats || { shareCount: 0 }, + music: v.music || {}, + author, + video, + AIGCDescription: v.AIGCDescription, + CategoryType: v.CategoryType, + collected: v.collected, + contents: v.contents || [], + challenges: v.challenges || [], + textExtra: v.textExtra || [], + textLanguage: v.textLanguage, + textTranslatable: v.textTranslatable, + titleLanguage: v.titleLanguage, + titleTranslatable: v.titleTranslatable, + isAd: v.isAd, + isReviewing: v.isReviewing, + itemCommentStatus: v.itemCommentStatus, + item_control: v.item_control, + duetDisplay: v.duetDisplay, + stitchDisplay: v.stitchDisplay, + diversificationId: v.diversificationId, + backendSourceEventTracking: v.backendSourceEventTracking, + stickersOnItem: v.stickersOnItem || [], + videoSuggestWordsList: v.videoSuggestWordsList + }) + } + }) + + hasMore = result.hasMore + responseCursor = hasMore ? result.cursor : 0 + page++ + counter++ + + // Check post limit if specified + if (postLimit && posts.length >= postLimit) { + hasMore = false + break + } + } + + return postLimit ? posts.slice(0, postLimit) : posts +} + +const requestUserReposts = async ( + proxy?: string, + secUid: string = "", + cursor: number = 0, + count: number = 0 +): Promise => { + return retry( + async (bail, attempt) => { + try { + let urlParams = _getUserRepostsParams(secUid, cursor, count) + + const { data } = await Axios.get(`${_tiktokGetReposts(urlParams)}`, { + headers: { + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0" + }, + httpsAgent: + (proxy && + (proxy.startsWith("http") || proxy.startsWith("https") + ? new HttpsProxyAgent(proxy) + : proxy.startsWith("socks") + ? new SocksProxyAgent(proxy) + : undefined)) || + undefined + }) + + if (data === "") { + throw new Error("Empty response") + } + + return data + } catch (error) { + if ( + error.response?.status === 400 || + error.response?.data?.statusCode === 10201 + ) { + bail(new Error("Video not found!")) + return + } + throw error + } + }, + { + retries: 10, + minTimeout: 1000, + maxTimeout: 5000, + factor: 2, + onRetry: (error, attempt) => { + console.log(`Retry attempt ${attempt} due to: ${error}`) + } + } + ) +} diff --git a/test/userreposts-test.ts b/test/userreposts-test.ts new file mode 100644 index 0000000..37fd726 --- /dev/null +++ b/test/userreposts-test.ts @@ -0,0 +1,57 @@ +// Test for Tiktok Get User Reposts +import Tiktok from "../src/index" + +async function testUserReposts() { + try { + const username = "Tobz2k19" // Change to a valid TikTok username + const result = await Tiktok.GetUserReposts(username, { + postLimit: 30, + proxy: undefined + }) + if (result.status === "success" && result.result) { + console.log("\nUser Reposts fetched successfully!") + console.log("========================") + console.log("Reposts Overview:") + console.log("========================") + console.log(`Total reposts fetched: ${result.result.length}`) + + result.result.forEach((post, index) => { + console.log(`\nRepost ${index + 1}:`) + console.log("-------------------") + console.log(`ID: ${post.id}`) + console.log(`Description: ${post.desc}`) + if (post.author) { + console.log( + `Author: ${post.author.nickname} (@${post.author.username})` + ) + } + console.log( + `Create Time: ${new Date(post.createTime * 1000).toLocaleString()}` + ) + if (post.stats) { + console.log(`Share Count: ${post.stats.shareCount}`) + } + if (post.video) { + console.log(`Video Duration: ${post.video.duration}s`) + console.log(`Video Format: ${post.video.format}`) + } + if ( + post.imagePost && + post.imagePost.images && + post.imagePost.images.length > 0 + ) { + console.log(`Image Post: ${post.imagePost.images.length} images`) + } + if (post.music && post.music.title) { + console.log(`Music: ${post.music.title}`) + } + }) + } else { + console.error("Failed to fetch reposts:", result.message) + } + } catch (error) { + console.error("Error:", error) + } +} + +testUserReposts()