Files
Wyview/lib/tiktok.ts
2026-03-27 00:58:38 +01:00

211 lines
7.0 KiB
TypeScript

const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize";
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/";
const TIKTOK_USER_INFO_URL = "https://open.tiktokapis.com/v2/user/info/";
const TIKTOK_VIDEO_LIST_URL = "https://open.tiktokapis.com/v2/video/list/";
const CLIENT_KEY = process.env.TIKTOK_CLIENT_KEY!;
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
function getRedirectUri(): string {
return process.env.NODE_ENV === "production"
? process.env.TIKTOK_REDIRECT_URI_PROD!
: process.env.TIKTOK_REDIRECT_URI_DEV!;
}
// ── PKCE helpers ──────────────────────────────────────────────
export function generateCodeVerifier(): string {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const array = new Uint8Array(64);
crypto.getRandomValues(array);
for (let i = 0; i < 64; i++) {
result += charset[array[i] % charset.length];
}
return result;
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
const bytes = Array.from(new Uint8Array(digest));
return btoa(bytes.map((b) => String.fromCharCode(b)).join(""))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// ──────────────────────────────────────────────────────────────
export function getTikTokAuthUrl(state: string, codeChallenge: string): string {
const params = new URLSearchParams({
client_key: CLIENT_KEY,
response_type: "code",
// video.list is used as a fallback to compute total views from all videos.
scope: "user.info.basic,user.info.stats,video.list",
redirect_uri: getRedirectUri(),
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
// "select_account" seul est ignoré par TikTok si une session est active.
// On passe les deux pour forcer l'affichage du sélecteur de compte à chaque fois.
prompt: "select_account",
force_login: "true",
});
return `${TIKTOK_AUTH_URL}?${params.toString()}`;
}
export interface TikTokTokenResponse {
access_token: string;
refresh_token: string;
open_id: string;
expires_in: number;
refresh_expires_in: number;
token_type: string;
scope: string;
error?: string;
error_description?: string;
}
export async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise<TikTokTokenResponse> {
const body = new URLSearchParams({
client_key: CLIENT_KEY,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: getRedirectUri(),
code_verifier: codeVerifier,
});
const res = await fetch(TIKTOK_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error_description || data.error || "TikTok token exchange failed");
}
return data;
}
export async function refreshAccessToken(refreshToken: string): Promise<TikTokTokenResponse> {
const body = new URLSearchParams({
client_key: CLIENT_KEY,
client_secret: CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: refreshToken,
});
const res = await fetch(TIKTOK_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error_description || data.error || "TikTok token refresh failed");
}
return data;
}
export interface TikTokUserStats {
followers: number;
likes: number;
videoCount: number;
views: number;
profileViews: number;
username: string;
displayName: string;
avatarUrl: string;
}
async function fetchTotalVideoViews(accessToken: string): Promise<number> {
const fields = "id,view_count";
let cursor = 0;
let totalViews = 0;
for (let page = 0; page < 50; page++) {
const res = await fetch(`${TIKTOK_VIDEO_LIST_URL}?fields=${fields}`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ max_count: 20, cursor }),
});
const data = await res.json();
if (!res.ok || data.error?.code !== "ok") {
throw new Error(data.error?.message || "TikTok video list fetch failed");
}
const videos = Array.isArray(data.data?.videos) ? data.data.videos : [];
for (const video of videos) {
totalViews += Number(video?.view_count ?? 0);
}
const hasMore = Boolean(data.data?.has_more);
if (!hasMore) {
break;
}
const nextCursor = Number(data.data?.cursor ?? cursor + videos.length);
if (!Number.isFinite(nextCursor) || nextCursor <= cursor) {
break;
}
cursor = nextCursor;
}
return totalViews;
}
export async function fetchUserStats(accessToken: string, openId: string): Promise<TikTokUserStats> {
const fields = "follower_count,following_count,likes_count,video_count,video_view_count,profile_view_count,display_name,avatar_url";
const url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const data = await res.json();
if (!res.ok || data.error?.code !== "ok") {
throw new Error(data.error?.message || "TikTok user info fetch failed");
}
const user = data.data?.user ?? {};
let views = user.video_view_count ?? user.profile_view_count ?? 0;
// Some TikTok apps do not receive video_view_count in user.info.stats.
// In that case we compute total views from the account video list.
if (user.video_view_count == null) {
try {
const computedViews = await fetchTotalVideoViews(accessToken);
if (computedViews > 0) {
views = computedViews;
}
} catch (err) {
console.warn("[TikTok views fallback error]", err);
}
}
return {
followers: user.follower_count ?? 0,
likes: user.likes_count ?? 0,
videoCount: user.video_count ?? 0,
views,
profileViews: user.profile_view_count ?? 0,
username: openId,
displayName: user.display_name ?? "",
avatarUrl: user.avatar_url ?? "",
};
}