diff --git a/src/index.ts b/src/index.ts index 7204441..f9e9c64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,16 @@ import { Hono } from 'hono' import { cache } from 'hono/cache' -import { scrapeVideoData } from './services/tiktok' +import { scrapeLiveData, scrapeVideoData } from './services/tiktok' import { grabAwemeId } from './services/tiktok' -import { VideoResponse, ErrorResponse } from './templates' +import { VideoResponse, ErrorResponse, LiveResponse } from './templates' import generateAlternate from './util/generateAlternate' import { returnHTMLResponse } from './util/responseHelper' const app = new Hono() const awemeIdPattern = /^\d{1,19}$/ +const awemeLinkPattern = /\/@([\w\d_.]+)\/(video|photo|live)\/?(\d{19})?/ + const BOT_REGEX = /bot|facebook|embed|got|firefox\/92|curl|wget|go-http|yahoo|generator|whatsapp|discord|preview|link|proxy|vkshare|images|analyzer|index|crawl|spider|python|cfnetwork|node/gi @@ -43,30 +45,68 @@ app.get('/', (c) => { }) }) -async function handleVideo(c: any): Promise { +async function handleShort(c: any): Promise { const { videoId } = c.req.param() - let id = videoId.split('.')[0] + let id = videoId.split('.')[0] // for .mp4, .webp, etc. + + // First, we need to find where the vm link goes to + const res = await fetch('https://vm.tiktok.com/' + videoId) + const link = new URL(res.url) + + // Clean any tracking parameters + link.search = '' // If the user agent is a bot, redirect to the TikTok page if (!BOT_REGEX.test(c.req.header('User-Agent') || '')) { return new Response('', { status: 302, headers: { - Location: 'https://www.tiktok.com' + `${awemeIdPattern.test(id) ? c.req.path : '/t/' + id}` + Location: 'https://www.tiktok.com' + link.pathname } }) } - // If the videoId doesn't match the awemeIdPattern, that means we have shortened TikTok link and we need to grab the awemeId - if (!awemeIdPattern.test(id)) { - try { - const awemeId = await grabAwemeId(id) - id = awemeId - } catch (e) { - const responseContent = await ErrorResponse((e as Error).message) - return returnHTMLResponse(responseContent, 500) - } + // Now, we need to check if the video is a livestream or a photo/video + if (link.pathname.includes('/video') || link.pathname.includes('/photo')) { + return handleVideo(c) + } else if (link.pathname.includes('/live')) { + return handleLive(c) + } else { + const responseContent = await ErrorResponse('Invalid vm link') + return returnHTMLResponse(responseContent, 400) } +} + +async function handleVideo(c: any): Promise { + const { videoId } = c.req.param() + let id = videoId.split('.')[0] // for .mp4, .webp, etc. + + // If the user agent is a bot, redirect to the TikTok page + if (!BOT_REGEX.test(c.req.header('User-Agent') || '')) { + const url = new URL(c.req.url) + + // Remove tracking parameters + url.search = '' + + return new Response('', { + status: 302, + headers: { + Location: 'https://www.tiktok.com' + url.pathname + } + }) + } + + if(!awemeIdPattern.test(id)) { + const url = await grabAwemeId(id) + const match = url.pathname.match(awemeLinkPattern) + + if (match) { + id = match[3] + } else { + const responseContent = await ErrorResponse('Invalid video ID') + return returnHTMLResponse(responseContent, 400) + } + } try { const videoInfo = await scrapeVideoData(id) @@ -108,6 +148,54 @@ async function handleVideo(c: any): Promise { return returnHTMLResponse(responseContent, 500) } } +async function handleLive(c: any): Promise { + const { author, videoId } = c.req.param() + let authorName = author; + + // If the user agent is a bot, redirect to the TikTok page + if (!BOT_REGEX.test(c.req.header('User-Agent') || '')) { + const url = new URL(c.req.url) + + // Remove tracking parameters + url.search = '' + + return new Response('', { + status: 302, + headers: { + Location: 'https://www.tiktok.com' + url.pathname + } + }) + } + + if(!author && !awemeIdPattern.test(videoId)) { + const url = await grabAwemeId(videoId) + const match = url.pathname.match(awemeLinkPattern) + + if (match) { + authorName = match[1] + } else { + const responseContent = await ErrorResponse('Invalid live ID') + return returnHTMLResponse(responseContent, 400) + } + } + + authorName = authorName.startsWith('@') ? authorName.substring(1) : authorName + + try { + const liveData = await scrapeLiveData(authorName) + + if (liveData instanceof Error) { + const responseContent = await ErrorResponse((liveData as Error).message) + return returnHTMLResponse(responseContent, 500) + } + + const responseContent = await LiveResponse(liveData) + return returnHTMLResponse(responseContent) + } catch (e) { + const responseContent = await ErrorResponse((e as Error).message) + return returnHTMLResponse(responseContent, 500) + } +} app.get('/generate/alternate', (c) => { const content = JSON.stringify(generateAlternate(c)) @@ -209,7 +297,11 @@ app.get('/generate/image/:videoId/:imageCount', async (c) => { const routes = [ { path: '/:videoId', - handler: handleVideo + handler: handleShort + }, + { + path: '/t/:videoId', + handler: handleShort }, { path: '/*/video/:videoId', @@ -220,8 +312,8 @@ const routes = [ handler: handleVideo }, { - path: '/t/:videoId', - handler: handleVideo + path: '/:author/live', + handler: handleLive } ] diff --git a/src/services/tiktok.ts b/src/services/tiktok.ts index b20cf89..c688141 100644 --- a/src/services/tiktok.ts +++ b/src/services/tiktok.ts @@ -1,22 +1,13 @@ import { WebJSONResponse, ItemStruct } from '../types/Web' +import { LiveWebJSONResponse, LiveRoom } from '../types/Live' import Cookie from '../util/cookieHelper' import cookieParser from 'set-cookie-parser' const cookie = new Cookie([]) -export async function grabAwemeId(videoId: string): Promise { - // https://vm.tiktok.com/ZMJmVWVpL/ +export async function grabAwemeId(videoId: string): Promise { const res = await fetch('https://vm.tiktok.com/' + videoId) - const url = new URL(res.url) - - const awemeIdPattern = /\/@[\w\d_.]+\/(video|photo)\/(\d{19})/ - const match = url.pathname.match(awemeIdPattern) - - if (match) { - return match[2] - } else { - throw new Error('Could not find awemeId') - } + return new URL(res.url) } export async function scrapeVideoData(awemeId: string, author?: string): Promise { @@ -56,3 +47,36 @@ export async function scrapeVideoData(awemeId: string, author?: string): Promise throw new Error('Could not parse video info') } } + +export async function scrapeLiveData(author: string): Promise { + const res = await fetch(`https://www.tiktok.com/@${author}/live`, { + method: 'GET', + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + Cookie: cookie.getCookiesAsString() + }, + cf: { + cacheEverything: true, + cacheTtlByStatus: { '200-299': 86400, 404: 1, '500-599': 0 } + } + }) + + let cookies = cookieParser(res.headers.get('set-cookie')!) + cookie.setCookies(cookies) + + const html = await res.text() + + try { + const resJson = html + .split('')[0] + const json: LiveWebJSONResponse = JSON.parse(resJson) + + if (!json['LiveRoom']) throw new Error('Could not find live data') + + return json['LiveRoom'] + } catch (err) { + throw new Error('Could not parse live data') + } +} \ No newline at end of file diff --git a/src/templates/index.ts b/src/templates/index.ts index b8079fd..cf2be40 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -1,2 +1,3 @@ export * from './pages/VideoResponse' export * from './pages/Error' +export * from './pages/LiveResponse' \ No newline at end of file diff --git a/src/templates/pages/Error.tsx b/src/templates/pages/Error.tsx index 3e0a4c3..a80a627 100644 --- a/src/templates/pages/Error.tsx +++ b/src/templates/pages/Error.tsx @@ -15,7 +15,7 @@ export function ErrorResponse(error: string): JSX.Element { }, { name: 'og:description', - content: 'An error occurred while trying to fetch the video. Please try again later.' + content: 'An error occurred while trying to fetch data. Please try again later.' }, { name: 'og:site_name', diff --git a/src/templates/pages/LiveResponse.tsx b/src/templates/pages/LiveResponse.tsx new file mode 100644 index 0000000..62b29e4 --- /dev/null +++ b/src/templates/pages/LiveResponse.tsx @@ -0,0 +1,47 @@ +import MetaHelper from '../../util/metaHelper' +import { LiveRoom } from '../../types/Live' + +export function LiveResponse(data: LiveRoom): JSX.Element { + return ( + <> + {MetaHelper([ + { + name: 'og:title', + content: `🔴 ${data.liveRoomUserInfo.user.nickname} (@${data.liveRoomUserInfo.user.nickname})` + }, + { + name: 'theme-color', + content: '#ff0050' + }, + { + name: 'twitter:site', + content: `@${data.liveRoomUserInfo.user.uniqueId}` // @username + }, + { + name: 'twitter:creator', + content: `@${data.liveRoomUserInfo.user.uniqueId}` // @username + }, + { + name: 'twitter:title', + content: `🔴 ${data.liveRoomUserInfo.user.nickname} (@${data.liveRoomUserInfo.user.nickname})` // Nickname (@username) + }, + { + name: 'og:url', + content: `https://www.tiktok.com/@${data.liveRoomUserInfo.user.uniqueId}/live` + }, + { + name: 'og:description', + content: data.liveRoomUserInfo.liveRoom.title + }, + { + name: 'og:image', + content: data.liveRoomUserInfo.user.avatarMedium + } + ], { + unique_id: data.liveRoomUserInfo.user.uniqueId, + viewers: data.liveRoomUserInfo.liveRoom.liveRoomStats.userCount, + startTime: data.liveRoomUserInfo.liveRoom.startTime, + })} + + ) +} \ No newline at end of file diff --git a/src/templates/pages/VideoResponse.tsx b/src/templates/pages/VideoResponse.tsx index 920ce67..5a6e23c 100644 --- a/src/templates/pages/VideoResponse.tsx +++ b/src/templates/pages/VideoResponse.tsx @@ -24,7 +24,7 @@ export function VideoResponse(data: ItemStruct): JSX.Element { let videoMeta: { name: string; content: string }[] = [] - if (data.video.duration !== 0) { + if(data.video.duration !== 0) { videoMeta = [ { name: 'og:video', @@ -58,28 +58,28 @@ export function VideoResponse(data: ItemStruct): JSX.Element { videoMeta = [ ...videoMeta, { - name: 'og:image', + name: "og:image", content: data.imagePost.images[i].imageURL.urlList[0] }, { - name: 'og:image:type', - content: 'image/jpeg' + name: "og:image:type", + content: "image/jpeg" }, { - name: 'og:image:width', - content: 'auto' + name: "og:image:width", + content: "auto" }, { - name: 'og:image:height', - content: 'auto' + name: "og:image:height", + content: "auto" }, { - name: 'og:type', - content: 'image.other' + name: "og:type", + content: "image.other" }, { - name: 'twitter:card', - content: 'summary_large_image' + name: "twitter:card", + content: "summary_large_image" } ] } diff --git a/src/tests/photo.test.ts b/src/tests/photo.test.ts index 4695ea0..1127028 100644 --- a/src/tests/photo.test.ts +++ b/src/tests/photo.test.ts @@ -30,7 +30,7 @@ describe('GET /@i/photo/:videoId', () => { }) expect(res.status).toBe(500) - expect(await res.text()).toContain('An error occurred while trying to fetch the video. Please try again later.') + expect(await res.text()).toContain('An error occurred while trying to fetch data. Please try again later.') }) }) diff --git a/src/tests/video.test.ts b/src/tests/video.test.ts index 9117fa2..2e34605 100644 --- a/src/tests/video.test.ts +++ b/src/tests/video.test.ts @@ -30,7 +30,7 @@ describe('GET /@i/video/:videoId', () => { }) expect(res.status).toBe(500) - expect(await res.text()).toContain('An error occurred while trying to fetch the video. Please try again later.') + expect(await res.text()).toContain('An error occurred while trying to fetch data. Please try again later.') }) }) diff --git a/src/types/Live.ts b/src/types/Live.ts new file mode 100644 index 0000000..2ec9a0f --- /dev/null +++ b/src/types/Live.ts @@ -0,0 +1,138 @@ +export interface LiveWebJSONResponse { + LiveRoom: LiveRoom +} + +export interface LiveRoom { + loadingState: LoadingState + needLogin: boolean + showLiveGate: boolean + isAgeGateRoom: boolean + recommendLiveRooms: any[] + liveRoomStatus: number + liveRoomUserInfo: LiveRoomUserInfo +} + +export interface LoadingState { + getRecommendLive: number + getUserInfo: number + getUserStat: number +} + +export interface LiveRoomUserInfo { + user: User + stats: Stats + liveRoom: LiveRoom2 +} + +export interface User { + avatarLarger: string + avatarMedium: string + avatarThumb: string + id: string + nickname: string + secUid: string + secret: boolean + uniqueId: string + verified: boolean + roomId: string + signature: string + status: number +} + +export interface Stats { + followingCount: number + followerCount: number +} + +export interface LiveRoom2 { + coverUrl: string + title: string + startTime: number + status: number + paidEvent: PaidEvent + liveSubOnly: number + liveRoomMode: number + hashTagId: number + gameTagId: number + liveRoomStats: LiveRoomStats + streamData: StreamData + streamId: string + multiStreamScene: number + multiStreamSource: number + hevcStreamData: HevcStreamData +} + +export interface PaidEvent { + event_id: number + paid_type: number +} + +export interface LiveRoomStats { + userCount: number +} + +export interface StreamData { + pull_data: PullData +} + +export interface PullData { + options: Options + stream_data: string +} + +export interface Options { + default_quality: DefaultQuality + qualities: Quality[] + show_quality_button: boolean +} + +export interface DefaultQuality { + icon_type: number + level: number + name: string + resolution: string + sdk_key: string + v_codec: string +} + +export interface Quality { + icon_type: number + level: number + name: string + resolution: string + sdk_key: string + v_codec: string +} + +export interface HevcStreamData { + pull_data: PullData2 +} + +export interface PullData2 { + options: Options2 + stream_data: string +} + +export interface Options2 { + default_quality: DefaultQuality2 + qualities: Quality2[] + show_quality_button: boolean +} + +export interface DefaultQuality2 { + icon_type: number + level: number + name: string + resolution: string + sdk_key: string + v_codec: string +} + +export interface Quality2 { + icon_type: number + level: number + name: string + resolution: string + sdk_key: string + v_codec: string +} \ No newline at end of file diff --git a/src/util/generateAlternate.tsx b/src/util/generateAlternate.tsx index b162181..13866c3 100644 --- a/src/util/generateAlternate.tsx +++ b/src/util/generateAlternate.tsx @@ -16,6 +16,21 @@ function formatNumber(value: string): string { return (num / 1000000000).toFixed(0) + 'B' } +function formatTime(time: number): string { + const timeElapsed = Date.now() - (time * 1000); // time elapsed in milliseconds + const minutes = Math.floor(timeElapsed / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days}d ${hours % 24}h`; + } else if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else { + return `${minutes}m`; + } +} + export default function generateAlternate(c: Context): { version: string type: string @@ -25,13 +40,16 @@ export default function generateAlternate(c: Context): { provider_url: string title: string } { - const { likes, comments, shares, unique_id, images } = c.req.query() + const { likes, comments, shares, unique_id, images, viewers, startTime } = c.req.query() let author_name = '' if (likes) author_name += `❤️ ${formatNumber(likes)} ` if (comments) author_name += `💬 ${formatNumber(comments)} ` if (shares) author_name += `📤 ${formatNumber(shares)} ` - if (images) author_name += `🖼️ ${images}` + if (images) author_name += `🖼️ ${images} ` + if (viewers) author_name += `👀 ${formatNumber(viewers)} ` + if (startTime && !isNaN(Number(startTime))) author_name += `🕒 ${formatTime(parseInt(startTime))} ` + if(author_name.length > 0 && author_name[author_name.length - 1] === ' ') author_name = author_name.slice(0, -1) // remove trailing space return { version: '1.0', diff --git a/src/util/metaHelper.tsx b/src/util/metaHelper.tsx index 9b3e0ba..d6397ad 100644 --- a/src/util/metaHelper.tsx +++ b/src/util/metaHelper.tsx @@ -4,13 +4,17 @@ export default function MetaHelper( content: string | null }[], alternate?: { - likes: number - comments: number - shares: number - unique_id: string - images: number + [key: string]: string | number } ): JSX.Element { + let alternateUrl = new URL('https://fxtiktok-rewrite.dargy.workers.dev/generate/alternate') + + if (alternate) { + for (const key in alternate) { + alternateUrl.searchParams.set(key, encodeURIComponent(alternate[key].toString())) + } + } + return ( @@ -18,7 +22,7 @@ export default function MetaHelper( {alternate ? ( 0 ? '&images=' + alternate.images : ''}`} + href={alternateUrl.toString()} type='application/json+oembed' /> ) : null}