diff --git a/lib/routes/tiktok/types.ts b/lib/routes/tiktok/types.ts new file mode 100644 index 000000000..6a95a6381 --- /dev/null +++ b/lib/routes/tiktok/types.ts @@ -0,0 +1,273 @@ +export type Item = { + AIGCDescription: string; + CategoryType: number; + author: { + UserStoryStatus: number; + avatarLarger: string; + avatarMedium: string; + avatarThumb: string; + commentSetting: number; + downloadSetting: number; + duetSetting: number; + ftc: boolean; + id: string; + isADVirtual: boolean; + isEmbedBanned: boolean; + nickname: string; + openFavorite: boolean; + privateAccount: boolean; + relation: number; + secUid: string; + secret: boolean; + signature: string; + stitchSetting: number; + uniqueId: string; + verified: boolean; + }; + authorStats: { + diggCount: number; + followerCount: number; + followingCount: number; + friendCount: number; + heart: number; + heartCount: number; + videoCount: number; + }; + authorStatsV2: { + diggCount: string; + followerCount: string; + followingCount: string; + friendCount: string; + heart: string; + heartCount: string; + videoCount: string; + }; + backendSourceEventTracking: string; + collected: boolean; + createTime: number; + creatorAIComment: { + eligibleVideo: boolean; + hasAITopic: boolean; + notEligibleReason: number; + }; + desc: string; + digged: boolean; + duetDisplay: number; + duetEnabled: boolean; + forFriend: boolean; + id: string; + isAd: boolean; + isReviewing: boolean; + itemCommentStatus: number; + item_control: { + can_repost: boolean; + can_comment?: boolean; + can_creator_redirect?: boolean; + can_music_redirect?: boolean; + can_share?: boolean; + }; + music: { + authorName: string; + coverLarge: string; + coverMedium: string; + coverThumb: string; + duration: number; + id: string; + isCopyrighted: boolean; + is_commerce_music: boolean; + is_unlimited_music: boolean; + original: boolean; + playUrl: string; + private: boolean; + shoot_duration: number; + title: string; + tt2dsp: { + tt_to_dsp_song_infos: { + meta_song_id: string; + platform: number; + song_id: string; + token: { + apple_music_token: { + developer_token: string; + }; + }; + }[]; + }; + }; + officalItem: boolean; // Not a typo for "officialItem" + originalItem: boolean; + privateItem: boolean; + secret: boolean; + shareEnabled: boolean; + stats: { + collectCount: number; + commentCount: number; + diggCount: number; + playCount: number; + shareCount: number; + }; + statsV2: { + collectCount: string; + commentCount: string; + diggCount: string; + playCount: string; + repostCount: string; + shareCount: string; + }; + stitchDisplay: number; + stitchEnabled: boolean; + textLanguage: string; + textTranslatable: boolean; + video: { + PlayAddrStruct: { + DataSize: number; + FileCs: string; + FileHash: string; + Height: number; + Uri: string; + UrlKey: string; + UrlList: string[]; + Width: number; + }; + VQScore: string; + bitrate: number; + bitrateInfo: { + Bitrate: number; + BitrateFPS: number; + CodecType: string; + Format: string; + GearName: string; + MVMAF: string; + PlayAddr: { + DataSize: number; + FileCs: string; + FileHash: string; + Height: number; + Uri: string; + UrlKey: string; + UrlList: string[]; + Width: number; + }; + QualityType: number; + VideoExtra: string; + }[]; + claInfo: { + enableAutoCaption: boolean; + hasOriginalAudio: boolean; + noCaptionReason?: number; + captionInfos?: { + captionFormat: string; + claSubtitleID: string; + expire: string; + isAutoGen: boolean; + isOriginalCaption: boolean; + language: string; + languageCode: string; + languageID: string; + subID: string; + subtitleType: string; + translationType: string; + url: string; + urlList: string[]; + variant: string; + }[]; + captionsType?: number; + originalLanguageInfo?: { + canTranslateRealTimeNoCheck: boolean; + language: string; + languageCode: string; + languageID: string; + }; + }; + codecType: string; + cover: string; + definition: string; + downloadAddr: string; + duration: number; + dynamicCover: string; + encodeUserTag: string; + encodedType: string; + format: string; + height: number; + id: string; + originCover: string; + playAddr: string; + ratio: string; + size: number; + videoID: string; + videoQuality: string; + volumeInfo: { + Loudness: number; + Peak: number; + }; + width: number; + zoomCover: { + 240: string; + 480: string; + 720: string; + 960: string; + }; + subtitleInfos?: { + Format: string; + LanguageCodeName: string; + LanguageID: string; + Size: number; + Source: string; + Url: string; + UrlExpire: number; + Version: string; + }[]; + }; + challenges?: { + coverLarger: string; + coverMedium: string; + coverThumb: string; + desc: string; + id: string; + profileLarger: string; + profileMedium: string; + profileThumb: string; + title: string; + }[]; + textExtra?: Array<{ + awemeId: string; + end: number; + hashtagName: string; + isCommerce: boolean; + start: number; + subType: number; + type: number; + secUid?: string; + userId?: string; + userUniqueId?: string; + }>; + videoSuggestWordsList?: { + video_suggest_words_struct: { + hint_text: string; + scene: string; + words: Array<{ + word: string; + word_id: string; + }>; + }[]; + }; + diversificationId?: number; + contents?: { + desc: string; + textExtra?: { + awemeId: string; + end: number; + hashtagName: string; + isCommerce: boolean; + start: number; + subType: number; + type: number; + secUid?: string; + userId?: string; + userUniqueId?: string; + }[]; + }[]; + BAInfo?: string; + adAuthorization?: boolean; + adLabelVersion?: number; +}; diff --git a/lib/routes/tiktok/user.ts b/lib/routes/tiktok/user.ts index e7d405ad6..463d833cc 100644 --- a/lib/routes/tiktok/user.ts +++ b/lib/routes/tiktok/user.ts @@ -1,5 +1,7 @@ import path from 'node:path'; +import { load } from 'cheerio'; + import { config } from '@/config'; import type { Route } from '@/types'; import cache from '@/utils/cache'; @@ -8,6 +10,8 @@ import puppeteer from '@/utils/puppeteer'; import { queryToBoolean } from '@/utils/readable-social'; import { art } from '@/utils/render'; +import type { Item } from './types'; + const baseUrl = 'https://www.tiktok.com'; export const route: Route = { @@ -44,26 +48,36 @@ async function handler(ctx) { const browser = await puppeteer(); const page = await browser.newPage(); await page.setRequestInterception(true); + let itemList = { itemList: [] }; page.on('request', (request) => { - request.resourceType() === 'document' || request.resourceType() === 'script' ? request.continue() : request.abort(); + ['document', 'script', 'xhr', 'fetch'].includes(request.resourceType()) ? request.continue() : request.abort(); + }); + page.on('response', async (response) => { + const request = response.request(); + if (request.url().startsWith('https://www.tiktok.com/api/post/item_list/')) { + itemList = await response.json(); + } }); await page.goto(`${baseUrl}/${user}`, { waitUntil: 'networkidle0', }); - const SIGI_STATE = await page.evaluate(() => window.SIGI_STATE); + + const pageHtml = await page.content(); await browser.close(); - const lang = SIGI_STATE.AppContext.lang; - const SharingMetaState = SIGI_STATE.SharingMetaState; - const ItemModule = SIGI_STATE.ItemModule; + const $ = load(pageHtml); + const rehydrationData = JSON.parse($('script#__UNIVERSAL_DATA_FOR_REHYDRATION__').text()); + const userDetail = rehydrationData.__DEFAULT_SCOPE__['webapp.user-detail']; - return { lang, SharingMetaState, ItemModule }; + return { itemList, userDetail }; }, config.cache.routeExpire, false ); - const items = Object.values(data.ItemModule).map((item) => ({ + const { itemList, userDetail } = data; + + const items = itemList.itemList.map((item: Item) => ({ title: item.desc, description: art(path.join(__dirname, 'templates/user.art'), { poster: item.video.cover, @@ -71,18 +85,16 @@ async function handler(ctx) { useIframe, id: item.id, }), - author: item.nickname, + author: item.author.nickname, pubDate: parseDate(item.createTime, 'X'), - link: `${baseUrl}/@${item.author}/video/${item.id}`, - category: item.textExtra.map((t) => `#${t.hashtagName}`), + link: `${baseUrl}/@${item.author.uniqueId}/video/${item.id}`, })); return { - title: data.SharingMetaState.value['og:title'], - description: data.SharingMetaState.value['og:description'], - image: data.SharingMetaState.value['og:image'], + title: userDetail.shareMeta.title, + description: userDetail.shareMeta.desc, + image: userDetail.userInfo.user.avatarLarger, link: `${baseUrl}/${user}`, item: items, - language: data.lang, }; }