diff --git a/src/utils/downloader/tiktokAPIDownloader.ts b/src/utils/downloader/tiktokAPIDownloader.ts new file mode 100644 index 0000000..0102b43 --- /dev/null +++ b/src/utils/downloader/tiktokAPIDownloader.ts @@ -0,0 +1,299 @@ +import Axios from "axios" +import asyncRetry from "async-retry" +import { + _tiktokvFeed, + _tiktokurl, + _tiktokGetCollection, + _tiktokGetPlaylist +} from "../../constants/api" +import { + _tiktokApiParams, + _getCollectionParams, + _getPlaylistParams +} from "../../constants/params" +import { + AuthorTiktokAPI, + TiktokAPIResponse, + StatisticsTiktokAPI, + MusicTiktokAPI, + ResponseParserTiktokAPI, + VideoTiktokAPI +} from "../../types/downloader/tiktokApiDownloader" +import { HttpsProxyAgent } from "https-proxy-agent" +import { SocksProxyAgent } from "socks-proxy-agent" +import { ERROR_MESSAGES } from "../../constants" + +/** Constants */ +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)" + +/** Types */ +interface ProxyConfig { + httpsAgent?: HttpsProxyAgent | SocksProxyAgent +} + +/** Helper Functions */ +const createProxyAgent = (proxy?: string): ProxyConfig => { + if (!proxy) return {} + + const isHttpProxy = proxy.startsWith("http") || proxy.startsWith("https") + const isSocksProxy = proxy.startsWith("socks") + + if (!isHttpProxy && !isSocksProxy) return {} + + return { + httpsAgent: isHttpProxy + ? new HttpsProxyAgent(proxy) + : new SocksProxyAgent(proxy) + } +} + +const validateTikTokUrl = (url: string): boolean => { + return TIKTOK_URL_REGEX.test(url) +} + +const extractVideoId = (responseUrl: string): string | null => { + const matches = responseUrl.match(/\d{17,21}/g) + return matches ? matches[0] : null +} + +const parseStatistics = (content: any): StatisticsTiktokAPI => ({ + commentCount: content.statistics.comment_count, + likeCount: content.statistics.digg_count, + shareCount: content.statistics.share_count, + playCount: content.statistics.play_count, + downloadCount: content.statistics.download_count +}) + +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, + avatarThumb: content.author?.avatar_thumb?.url_list || [], + avatarMedium: content.author?.avatar_medium?.url_list || [], + url: `${_tiktokurl}/@${content.author.unique_id}` +}) + +const parseMusic = (content: any): MusicTiktokAPI => ({ + id: content.music.id, + title: content.music.title, + author: content.music.author, + album: content.music.album, + playUrl: content.music?.play_url?.url_list || [], + coverLarge: content.music?.cover_large?.url_list || [], + coverMedium: content.music?.cover_medium?.url_list || [], + coverThumb: content.music?.cover_thumb?.url_list || [], + duration: content.music.duration, + isCommerceMusic: content.music.is_commerce_music, + isOriginalSound: content.music.is_original_sound, + isAuthorArtist: content.music.is_author_artist +}) + +const parseVideo = (content: any): VideoTiktokAPI => ({ + ratio: content.video.ratio, + duration: content.video.duration, + playAddr: content.video?.play_addr?.url_list || [], + downloadAddr: content.video?.download_addr?.url_list || [], + cover: content.video?.cover?.url_list || [], + dynamicCover: content.video?.dynamic_cover?.url_list || [], + originCover: content.video?.origin_cover?.url_list || [] +}) + +const parseTiktokData = (ID: string, data: any): ResponseParserTiktokAPI => { + const content = data?.aweme_list?.find((v: any) => v.aweme_id === ID) + + if (!content) return { content: null } + + return { + content, + statistics: parseStatistics(content), + author: parseAuthor(content), + music: parseMusic(content) + } +} + +const fetchTiktokData = async ( + ID: string, + proxy?: string +): Promise => { + try { + const response = await asyncRetry( + async () => { + const res = await Axios( + _tiktokvFeed(_tiktokApiParams({ aweme_id: ID })), + { + method: "OPTIONS", + headers: { "User-Agent": USER_AGENT }, + ...createProxyAgent(proxy) + } + ) + + if (res.data && res.data.status_code === 0) { + return res.data + } + + throw new Error(ERROR_MESSAGES.NETWORK_ERROR) + }, + { + retries: 20, + minTimeout: 200, + maxTimeout: 1000 + } + ) + + return parseTiktokData(ID, response) + } catch (error) { + console.error("Error fetching TikTok data:", error) + return null + } +} + +const createImageResponse = ( + content: any, + author: AuthorTiktokAPI, + statistics: StatisticsTiktokAPI, + music: MusicTiktokAPI +): TiktokAPIResponse => ({ + status: "success", + result: { + type: "image", + id: content.aweme_id, + createTime: content.create_time, + desc: content.desc, + isTurnOffComment: content.item_comment_settings === 3, + hashtag: content.text_extra + .filter((x: any) => x.hashtag_name !== undefined) + .map((v: any) => v.hashtag_name), + isADS: content.is_ads, + author, + statistics, + images: + content.image_post_info.images?.map( + (v: any) => v?.display_image?.url_list[0] + ) || [], + music + } +}) + +const createVideoResponse = ( + content: any, + author: AuthorTiktokAPI, + statistics: StatisticsTiktokAPI, + music: MusicTiktokAPI +): TiktokAPIResponse => ({ + status: "success", + result: { + type: "video", + id: content.aweme_id, + createTime: content.create_time, + desc: content.desc, + isTurnOffComment: content.item_comment_settings === 3, + hashtag: content.text_extra + .filter((x: any) => x.hashtag_name !== undefined) + .map((v: any) => v.hashtag_name), + isADS: content.is_ads, + author, + statistics, + video: parseVideo(content), + music + } +}) + +export 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 + } +} + +/** + * Tiktok API Downloader + * @param {string} url - Tiktok URL + * @param {string} proxy - Your Proxy (optional) + * @param {boolean} showOriginalResponse - Show Original Response (optional) + * @returns {Promise} + */ +export const TiktokAPI = async ( + url: string, + proxy?: string, + showOriginalResponse?: boolean +): Promise => { + try { + if (!validateTikTokUrl(url)) { + return { + status: "error", + message: ERROR_MESSAGES.INVALID_URL + } + } + + // Normalize URL + url = url.replace("https://vm", "https://vt") + + // Get video ID + const { request } = await Axios(url, { + method: "HEAD", + ...createProxyAgent(proxy) + }) + + const videoId = extractVideoId(request.res.responseUrl) + if (!videoId) { + return { + status: "error", + message: ERROR_MESSAGES.INVALID_URL + } + } + + // Fetch TikTok data + const data = await fetchTiktokData(videoId, proxy) + if (!data?.content) { + return { + status: "error", + message: ERROR_MESSAGES.NETWORK_ERROR + } + } + + const { content, author, statistics, music } = data + + // Create response based on content type + const response = content.image_post_info + ? createImageResponse(content, author, statistics, music) + : createVideoResponse(content, author, statistics, music) + + // Return original response if requested + if (showOriginalResponse) { + return { + status: "success", + resultNotParsed: data + } + } + + return response + } 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 index 6fe7858..8479372 100644 --- a/src/utils/get/getCollection.ts +++ b/src/utils/get/getCollection.ts @@ -6,6 +6,10 @@ import { SocksProxyAgent } from "socks-proxy-agent" import { TiktokCollectionResponse } from "../../types/get/getCollection" import { ERROR_MESSAGES } from "../../constants" import retry from "async-retry" +import { handleRedirect } from "../downloader/tiktokAPIDownloader" + +/** Constants */ +const COLLECTION_URL_REGEX = /collection\/[^/]+-(\d+)/ /** Types */ interface ProxyConfig { @@ -44,9 +48,7 @@ export const getCollection = async ( const response = await retry( async () => { const res = await Axios( - _tiktokGetCollection( - _getCollectionParams(collectionId, page, count) - ), + _tiktokGetCollection(_getCollectionParams(collectionId, page, count)), { method: "GET", headers: { @@ -90,3 +92,79 @@ export const getCollection = 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 + } + } +} + +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 +} diff --git a/src/utils/get/getPlaylist.ts b/src/utils/get/getPlaylist.ts index ee0354e..c91853a 100644 --- a/src/utils/get/getPlaylist.ts +++ b/src/utils/get/getPlaylist.ts @@ -6,6 +6,10 @@ import { SocksProxyAgent } from "socks-proxy-agent" import { ERROR_MESSAGES } from "../../constants" import retry from "async-retry" import { TiktokPlaylistResponse } from "../../types/get/getPlaylist" +import { handleRedirect } from "../downloader/tiktokAPIDownloader" + +/** Constants */ +const PLAYLIST_URL_REGEX = /playlist\/[^/]+-(\d+)/ /** Types */ interface ProxyConfig { @@ -54,7 +58,7 @@ export const getPlaylist = async ( "Accept-Language": "en-US,en;q=0.7", Referer: "https://www.tiktok.com/", Origin: "https://www.tiktok.com", - "Content-Type": "application/json", + "Content-Type": "application/json" }, ...createProxyAgent(proxy) } @@ -78,7 +82,7 @@ export const getPlaylist = async ( result: { hasMore: response.hasMore, itemList: response.itemList || [], - extra: response.extra, + extra: response.extra } } } catch (error) { @@ -89,3 +93,79 @@ export const getPlaylist = async ( } } } + +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 + } + } +} + +export const extractPlaylistId = (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(PLAYLIST_URL_REGEX) + return match ? match[1] : null +} diff --git a/src/utils/search/videoSearch.ts b/src/utils/search/videoSearch.ts index 14e6211..06c31b3 100644 --- a/src/utils/search/videoSearch.ts +++ b/src/utils/search/videoSearch.ts @@ -3,7 +3,6 @@ import { _tiktokSearchVideoFull } from "../../constants/api" import { _liveSearchParams, _videoSearchParams } from "../../constants/params" import { SocksProxyAgent } from "socks-proxy-agent" import { HttpsProxyAgent } from "https-proxy-agent" -import { TiktokService } from "../../services/tiktokService" import retry from "async-retry" import { TiktokVideoSearchResponse,