140 lines
3.7 KiB
TypeScript
140 lines
3.7 KiB
TypeScript
import "server-only";
|
|
|
|
let cachedAccessToken: string | null = null;
|
|
let tokenExpiresAt = 0;
|
|
|
|
async function getAccessToken(): Promise<string | null> {
|
|
// Return cached token if still valid (with 5 min buffer)
|
|
if (cachedAccessToken && Date.now() < tokenExpiresAt - 300_000) {
|
|
return cachedAccessToken;
|
|
}
|
|
|
|
const clientKey = process.env.TIKTOK_CLIENT_KEY;
|
|
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
|
const refreshToken = process.env.TIKTOK_REFRESH_TOKEN;
|
|
|
|
if (!clientKey || !clientSecret || !refreshToken) return null;
|
|
|
|
try {
|
|
const res = await fetch("https://open.tiktokapis.com/v2/oauth/token/", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
client_key: clientKey,
|
|
client_secret: clientSecret,
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
}),
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
if (!res.ok) return null;
|
|
|
|
const data = await res.json();
|
|
if (data.access_token) {
|
|
cachedAccessToken = data.access_token;
|
|
tokenExpiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
|
|
return cachedAccessToken;
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export interface TiktokProfile {
|
|
followerCount: number | null;
|
|
displayName: string | null;
|
|
username: string | null;
|
|
avatarUrl: string | null;
|
|
}
|
|
|
|
export async function getTiktokProfile(): Promise<TiktokProfile> {
|
|
const empty: TiktokProfile = {
|
|
followerCount: null,
|
|
displayName: null,
|
|
username: null,
|
|
avatarUrl: null,
|
|
};
|
|
|
|
const accessToken = await getAccessToken();
|
|
if (!accessToken) return empty;
|
|
|
|
try {
|
|
const res = await fetch(
|
|
"https://open.tiktokapis.com/v2/user/info/?fields=follower_count,display_name,username,avatar_url",
|
|
{
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
cache: "no-store",
|
|
signal: AbortSignal.timeout(5000),
|
|
}
|
|
);
|
|
|
|
if (!res.ok) return empty;
|
|
|
|
const data = await res.json();
|
|
const user = data.data?.user;
|
|
if (!user) return empty;
|
|
|
|
return {
|
|
followerCount: user.follower_count != null ? Number(user.follower_count) : null,
|
|
displayName: user.display_name ?? null,
|
|
username: user.username ?? null,
|
|
avatarUrl: user.avatar_url ?? null,
|
|
};
|
|
} catch {
|
|
return empty;
|
|
}
|
|
}
|
|
|
|
/** Convenience wrapper that just returns the follower count */
|
|
export async function getTiktokFollowerCount(): Promise<number | null> {
|
|
const profile = await getTiktokProfile();
|
|
return profile.followerCount;
|
|
}
|
|
|
|
export interface TiktokVideo {
|
|
id: string;
|
|
title: string;
|
|
coverImageUrl: string;
|
|
shareUrl: string;
|
|
createTime: number;
|
|
}
|
|
|
|
export async function getLatestTiktokVideo(): Promise<TiktokVideo | null> {
|
|
const profile = await getTiktokProfile();
|
|
if (!profile.username) {
|
|
console.error("[TikTok API] Could not get username from profile to fetch TikWM.");
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`https://www.tikwm.com/api/user/posts?unique_id=@${profile.username}&count=1`, {
|
|
cache: "no-store",
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
|
|
if (!res.ok) return null;
|
|
|
|
const data = await res.json();
|
|
const videos = data.data?.videos;
|
|
|
|
if (!videos || videos.length === 0) return null;
|
|
|
|
const v = videos[0];
|
|
const shareUrl = `https://www.tiktok.com/@${profile.username}/video/${v.video_id}`;
|
|
|
|
return {
|
|
id: v.video_id,
|
|
title: v.title ?? "",
|
|
coverImageUrl: v.cover ?? "",
|
|
shareUrl: shareUrl,
|
|
createTime: v.create_time ?? 0,
|
|
};
|
|
} catch (e) {
|
|
console.error("[TikTok API] Error fetching latest video via TikWM:", e);
|
|
return null;
|
|
}
|
|
}
|