diff --git a/app/api/tiktok/snapshots/route.ts b/app/api/tiktok/snapshots/route.ts index 1ed2e54..30dc9da 100644 --- a/app/api/tiktok/snapshots/route.ts +++ b/app/api/tiktok/snapshots/route.ts @@ -77,6 +77,7 @@ export async function GET(req: NextRequest) { followers: true, likes: true, videoCount: true, + views: true, }, }); @@ -88,7 +89,7 @@ export async function POST(req: NextRequest) { if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const body = await req.json(); - const { followers, likes, videoCount, displayName, openId } = body; + const { followers, likes, videoCount, views, displayName, openId } = body; if (followers === undefined) { return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 }); @@ -115,7 +116,7 @@ export async function POST(req: NextRequest) { followers: followers ?? 0, likes: likes ?? 0, videoCount: videoCount ?? 0, - views: 0, + views: views ?? 0, }, }); diff --git a/app/api/tiktok/stats/route.ts b/app/api/tiktok/stats/route.ts index b70e58f..a0e4421 100644 --- a/app/api/tiktok/stats/route.ts +++ b/app/api/tiktok/stats/route.ts @@ -21,6 +21,8 @@ export async function GET() { followers: 124, likes: 856, videoCount: 1, + views: 3432, + profileViews: 287, displayName: "CrowMate studio", avatarUrl: "", plan: (user as any)?.plan ?? "free", @@ -56,8 +58,7 @@ export async function GET() { try { const stats = await fetchUserStats(accessToken, openId); - - // Upsert TrackedAccount + snapshot automatique + try { let account = await prisma.trackedAccount.findFirst({ where: { userId, platform: "tiktok" }, @@ -78,14 +79,12 @@ export async function GET() { followers: stats.followers ?? 0, likes: stats.likes ?? 0, videoCount: stats.videoCount ?? 0, - views: 0, + views: stats.views ?? 0, }, }); } catch (snapshotErr) { console.error("[TikTok snapshot save error]", snapshotErr); } - - // Inclure le plan dans la réponse const user = await prisma.user.findUnique({ where: { id: userId } }); return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" }); } catch (err) { diff --git a/app/tiktok/page.tsx b/app/tiktok/page.tsx index 7eff67f..3143483 100644 --- a/app/tiktok/page.tsx +++ b/app/tiktok/page.tsx @@ -11,6 +11,7 @@ interface TikTokStats { followers: number; likes: number; videoCount: number; + views: number; displayName: string; avatarUrl: string; plan: "free" | "pro" | "elite" | "team"; @@ -120,7 +121,7 @@ export default function TikTokPage() { )} -
+
+ 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"} diff --git a/components/StatsCharts.tsx b/components/StatsCharts.tsx index ade19c6..f2e8914 100644 --- a/components/StatsCharts.tsx +++ b/components/StatsCharts.tsx @@ -13,13 +13,14 @@ import { import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react"; type Period = "7d" | "30d" | "90d" | "all"; -type Metric = "followers" | "likes" | "videoCount"; +type Metric = "followers" | "likes" | "videoCount" | "views"; interface Snapshot { createdAt: string; followers: number; likes: number; videoCount: number; + views: number; } interface StatsChartProps { @@ -39,6 +40,7 @@ const METRICS: { value: Metric; label: string; color: string; gradientId: string { value: "followers", label: "Followers", color: "#c084fc", gradientId: "gradFollowers" }, { value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" }, { value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" }, + { value: "views", label: "Vues", color: "#22c55e", gradientId: "gradViews" }, ]; function canAccess(plan: string, required: string) { diff --git a/lib/tiktok.ts b/lib/tiktok.ts index 4005d88..a2a8324 100644 --- a/lib/tiktok.ts +++ b/lib/tiktok.ts @@ -1,6 +1,7 @@ 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!; @@ -38,7 +39,8 @@ export function getTikTokAuthUrl(state: string, codeChallenge: string): string { const params = new URLSearchParams({ client_key: CLIENT_KEY, response_type: "code", - scope: "user.info.basic,user.info.stats", + // 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, @@ -115,13 +117,57 @@ 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,display_name,avatar_url"; + 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, { @@ -137,11 +183,27 @@ export async function fetchUserStats(accessToken: string, openId: string): Promi } 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 ?? "", diff --git a/worker/snapshot-worker.ts b/worker/snapshot-worker.ts index 0e30a15..4a5a771 100644 --- a/worker/snapshot-worker.ts +++ b/worker/snapshot-worker.ts @@ -17,6 +17,7 @@ const prisma = new PrismaClient({ adapter }); 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!; const INTERVAL_MS = 60 * 60 * 1000; // 1 heure @@ -57,7 +58,7 @@ async function refreshTikTokToken(refreshTokenStr: string) { } async function fetchTikTokStats(accessToken: string) { - const fields = "follower_count,likes_count,video_count,display_name"; + const fields = "follower_count,likes_count,video_count,video_view_count,display_name"; const res = await fetch(`${TIKTOK_USER_INFO_URL}?fields=${fields}`, { headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -68,14 +69,66 @@ async function fetchTikTokStats(accessToken: string) { } const user = data.data?.user ?? {}; + let views = (user.video_view_count ?? user.profile_view_count ?? 0) as number; + + if (user.video_view_count == null) { + try { + views = await fetchTotalVideoViews(accessToken); + } catch (err) { + console.warn("[worker] fallback views failed:", err); + } + } + return { followers: (user.follower_count ?? 0) as number, likes: (user.likes_count ?? 0) as number, videoCount: (user.video_count ?? 0) as number, + views, displayName: (user.display_name ?? "") as 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 ?? "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; +} + // ── Core job ────────────────────────────────────────────────── async function runSnapshots() { @@ -136,11 +189,11 @@ async function runSnapshots() { followers: stats.followers, likes: stats.likes, videoCount: stats.videoCount, - views: 0, + views: stats.views, }, }); - console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount}`); + console.log(`[worker] ✓ userId=${userId} — followers=${stats.followers} likes=${stats.likes} videos=${stats.videoCount} views=${stats.views}`); } catch (err) { console.error(`[worker] ✗ erreur userId=${userId}:`, err);