import type { Page } from 'playwright'; import { ChallengeError, ResolveError, oneSegment, type Media, type Post } from '../types.ts'; import { cookieHeaderFor } from '../browser/pool.ts'; import { CHALLENGE_SELECTORS, park } from '../challenge/registry.ts'; import { config } from '../config.ts'; import { readInlineJson, readOpenGraph } from '../browser/capture.ts'; import { hostMatcher } from './url.ts'; import type { PlatformSpec, ResolveContext } from './types.ts'; const REFERER = 'https://www.tiktok.com/'; type UrlList = { UrlList?: string[] }; type ItemStruct = { id?: string; desc?: string; createTime?: number | string; author?: { uniqueId?: string; nickname?: string; avatarMedium?: string; avatarLarger?: string }; video?: { playAddr?: string; downloadAddr?: string; cover?: string; duration?: number; width?: number; height?: number; bitrateInfo?: Array<{ Bitrate?: number; PlayAddr?: UrlList }>; }; imagePost?: { images?: Array<{ imageURL?: UrlList; imageWidth?: number; imageHeight?: number }>; }; }; type Rehydration = { __DEFAULT_SCOPE__?: Record; }; /** * The post lives under `webapp.video-detail` for a video and a different key * for a photo post, so take whichever scope actually carries one rather than * naming them. */ function findItemStruct(data: Rehydration | undefined): ItemStruct | undefined { for (const scope of Object.values(data?.__DEFAULT_SCOPE__ ?? {})) { const item = scope?.itemInfo?.itemStruct; if (item) return item; } return undefined; } /** * TikTok's CDN checks the referrer and, for some regions, the session * cookies the page was served with. The proxy replays both. */ async function tiktokHeaders(url: string): Promise> { const cookie = await cookieHeaderFor(url); return { Referer: REFERER, ...(cookie ? { Cookie: cookie } : {}), }; } /** Shared TikTok links lead with the author, e.g. `@user/video/123`. */ function handleFrom(url: string): string | undefined { const match = /tiktok\.com\/(@[\w.-]+)\//.exec(url); return match?.[1]; } function bestPlayAddr(video: NonNullable): string | undefined { const ranked = (video.bitrateInfo ?? []) .filter((entry) => entry.PlayAddr?.UrlList?.length) .sort((a, b) => (b.Bitrate ?? 0) - (a.Bitrate ?? 0))[0]; return ranked?.PlayAddr?.UrlList?.[0] ?? video.playAddr ?? video.downloadAddr; } export async function mediaFromItem(item: ItemStruct): Promise { // A TikTok photo post carries no video at all. const photos = item.imagePost?.images; if (photos?.length) { const results: Media[] = []; for (const photo of photos) { const url = photo.imageURL?.UrlList?.[0]; if (!url) continue; results.push({ kind: 'image', url, fetchHeaders: await tiktokHeaders(url), ...(photo.imageWidth ? { width: photo.imageWidth } : {}), ...(photo.imageHeight ? { height: photo.imageHeight } : {}), }); } return results; } const video = item.video; const src = video ? bestPlayAddr(video) : undefined; if (!video || !src) return []; return [{ kind: 'video', url: src, fetchHeaders: await tiktokHeaders(src), ...(video.cover ? { poster: { url: video.cover, fetchHeaders: await tiktokHeaders(video.cover) } } : {}), ...(video.duration ? { durationSec: video.duration } : {}), ...(video.width ? { width: video.width } : {}), ...(video.height ? { height: video.height } : {}), }]; } /** * TikTok sometimes answers with a slider puzzle instead of the post. * * It renders a moment after the document is ready, so this waits rather than * looking once — checking too early reports a broken adapter when the real * answer is "there is a puzzle in the way". */ async function isChallenged(page: Page): Promise { const appeared = await page .waitForSelector(CHALLENGE_SELECTORS, { state: 'visible', timeout: 5_000 }) .then(() => true) .catch(() => false); if (appeared) return true; return page .evaluate(() => /drag the slider|verify to continue/i.test(document.body?.innerText ?? '')) .catch(() => false); } async function resolve(ctx: ResolveContext): Promise { const { page, originalUrl, detach } = ctx; // Short links (`vm.`/`vt.`) are redirect stubs; loading one lands on the // real post, and `page.url()` is then the canonical address. await page.goto(originalUrl, { waitUntil: 'domcontentloaded' }); const landed = page.url(); const data = await readInlineJson( page, 'script#__UNIVERSAL_DATA_FOR_REHYDRATION__', config.navigationTimeoutMs, ); const item = findItemStruct(data); if (item) { const media = await mediaFromItem(item); if (media.length > 0) { const handle = item.author?.uniqueId ?? 'tiktok'; const avatar = item.author?.avatarLarger ?? item.author?.avatarMedium; const created = Number(item.createTime); return { platform: 'tiktok', platformLabel: 'TikTok', originalUrl: landed.startsWith('https://www.tiktok.com/') ? landed : originalUrl, author: { handle: `@${handle}`, ...(item.author?.nickname ? { displayName: item.author.nickname } : {}), ...(avatar ? { avatar: { url: avatar, fetchHeaders: await tiktokHeaders(avatar) } } : {}), }, textPosition: 'below', segments: oneSegment({ ...(item.desc ? { text: item.desc } : {}), media, ...(Number.isFinite(created) && created > 0 ? { postedAt: new Date(created * 1000).toISOString() } : {}), }), }; } } // Fall back to the link preview, which at least gets a poster on screen. if (await isChallenged(page)) { // Keep the page open and hand the puzzle to whoever is reading. Solving // it deposits the cookie in the shared browser context, so the retry // afterwards is an ordinary request. detach(); const id = await park({ page, platform: 'tiktok', platformLabel: 'TikTok', originalUrl, }); throw new ChallengeError(id, 'tiktok', originalUrl); } const og = await readOpenGraph(page); if (og['og:video'] ?? og['og:image']) { const url = og['og:video'] ?? (og['og:image'] as string); return { platform: 'tiktok', platformLabel: 'TikTok', originalUrl, author: { handle: handleFrom(landed) ?? handleFrom(originalUrl) ?? '@tiktok' }, textPosition: 'below', segments: oneSegment({ ...(og['og:description'] ? { text: og['og:description'] } : {}), media: [{ kind: og['og:video'] ? 'video' : 'image', url, fetchHeaders: await tiktokHeaders(url), }], }), }; } throw new ResolveError('TikTok would not return that post', 'tiktok', originalUrl); } export const tiktok: PlatformSpec = { id: 'tiktok', label: 'TikTok', prefix: 'tiktok', canonicalHost: 'www.tiktok.com', textPosition: 'below', matchesHost: hostMatcher('tiktok.com'), // A single opaque segment is a `vm.`/`vt.` share code, not a post path. buildOriginalUrl: (rest, search) => /^[A-Za-z0-9]+\/?$/.test(rest) ? `https://vm.tiktok.com/${rest.replace(/\/$/, '')}/` : `https://www.tiktok.com/${rest}${search}`, resolve, };