Files
antisocial/src/platforms/tiktok.ts
T
thatguygriffandClaude Opus 5 60a9468875
CI / Typecheck, test, build (pull_request) Successful in 46s
Show the author's own chain on Bluesky and Threads
People write in chains on both, and a link into one arrives pointing at a
single post out of several. Showing only that post loses the thing that was
being said. Other people's replies are a different matter: they are a
conversation rather than the thing that was shared, and on a busy post there
are hundreds of them.

A Post is now a list of Segments instead of one body. Most platforms produce
exactly one and say so through oneSegment(); the two that thread produce the
whole chain, with isAnchor marking the post that was actually linked, which
need not be the first.

Bluesky walks parent upward and the author's own replies downward, stopping at
the first post by anyone else. That needs depth and parentHeight on
getPostThread, which drags the entire reply tree along -- a few hundred KB on a
popular post -- because there is no way to ask the API for one author's branch.

Threads is harder to read. The page ships the linked post, the author's
follow-ups, other people's replies and a pile of unrelated recommendations, all
as flat thread_items containers with no nesting to go on. What separates a
follow-up from a stranger's reply is that a follow-up is the author replying to
themselves; a reply from someone else carries the same reply_to_author with a
different name on it. The first post of a chain replies to nothing at all, so
it is reachable only by walking backwards from the post that answers it -- a
test caught that, when linking the second post of a thread returned just the
one post.

Fixtures for both are real captures. The Bluesky one keeps two of every level's
outside replies rather than pruning them away, because a filter is only worth
testing against the thing it is supposed to exclude.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BGkRmLfiWuJHx6tQ12EELY
2026-08-26 17:16:02 -03:00

224 lines
7.3 KiB
TypeScript

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<string, { itemInfo?: { itemStruct?: ItemStruct } } | undefined>;
};
/**
* 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<Record<string, string>> {
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<ItemStruct['video']>): 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<Media[]> {
// 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<boolean> {
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<Post> {
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<Rehydration>(
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,
};