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 { 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 { 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 { 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 { 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 { 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 ?? "", }; }