feat: add the view graph on tiktok

This commit is contained in:
Pierre Ryssen
2026-03-27 00:58:38 +01:00
parent 51a376400c
commit 9bdbe8e153
6 changed files with 138 additions and 14 deletions

View File

@@ -77,6 +77,7 @@ export async function GET(req: NextRequest) {
followers: true, followers: true,
likes: true, likes: true,
videoCount: true, videoCount: true,
views: true,
}, },
}); });
@@ -88,7 +89,7 @@ export async function POST(req: NextRequest) {
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await req.json();
const { followers, likes, videoCount, displayName, openId } = body; const { followers, likes, videoCount, views, displayName, openId } = body;
if (followers === undefined) { if (followers === undefined) {
return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 }); return NextResponse.json({ error: "Champ 'followers' requis" }, { status: 400 });
@@ -115,7 +116,7 @@ export async function POST(req: NextRequest) {
followers: followers ?? 0, followers: followers ?? 0,
likes: likes ?? 0, likes: likes ?? 0,
videoCount: videoCount ?? 0, videoCount: videoCount ?? 0,
views: 0, views: views ?? 0,
}, },
}); });

View File

@@ -21,6 +21,8 @@ export async function GET() {
followers: 124, followers: 124,
likes: 856, likes: 856,
videoCount: 1, videoCount: 1,
views: 3432,
profileViews: 287,
displayName: "CrowMate studio", displayName: "CrowMate studio",
avatarUrl: "", avatarUrl: "",
plan: (user as any)?.plan ?? "free", plan: (user as any)?.plan ?? "free",
@@ -57,7 +59,6 @@ export async function GET() {
try { try {
const stats = await fetchUserStats(accessToken, openId); const stats = await fetchUserStats(accessToken, openId);
// Upsert TrackedAccount + snapshot automatique
try { try {
let account = await prisma.trackedAccount.findFirst({ let account = await prisma.trackedAccount.findFirst({
where: { userId, platform: "tiktok" }, where: { userId, platform: "tiktok" },
@@ -78,14 +79,12 @@ export async function GET() {
followers: stats.followers ?? 0, followers: stats.followers ?? 0,
likes: stats.likes ?? 0, likes: stats.likes ?? 0,
videoCount: stats.videoCount ?? 0, videoCount: stats.videoCount ?? 0,
views: 0, views: stats.views ?? 0,
}, },
}); });
} catch (snapshotErr) { } catch (snapshotErr) {
console.error("[TikTok snapshot save error]", snapshotErr); console.error("[TikTok snapshot save error]", snapshotErr);
} }
// Inclure le plan dans la réponse
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" }); return NextResponse.json({ ...stats, plan: (user as any)?.plan ?? "free" });
} catch (err) { } catch (err) {

View File

@@ -11,6 +11,7 @@ interface TikTokStats {
followers: number; followers: number;
likes: number; likes: number;
videoCount: number; videoCount: number;
views: number;
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
plan: "free" | "pro" | "elite" | "team"; plan: "free" | "pro" | "elite" | "team";
@@ -120,7 +121,7 @@ export default function TikTokPage() {
</div> </div>
)} )}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-2 xl:grid-cols-5 gap-4 mb-8">
<StatCard <StatCard
label="Followers" label="Followers"
value={stats.followers.toLocaleString("fr-FR")} value={stats.followers.toLocaleString("fr-FR")}
@@ -139,6 +140,12 @@ export default function TikTokPage() {
sub="Vidéos au total" sub="Vidéos au total"
accent="blue" accent="blue"
/> />
<StatCard
label="Vues vidéos"
value={stats.views.toLocaleString("fr-FR")}
sub="Vues totales des vidéos"
accent="green"
/>
<StatCard <StatCard
label="Ratio likes/vidéo" label="Ratio likes/vidéo"
value={stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"} value={stats.videoCount > 0 ? Math.round(stats.likes / stats.videoCount).toLocaleString("fr-FR") : "—"}

View File

@@ -13,13 +13,14 @@ import {
import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react"; import { TrendingUp, TrendingDown, Minus, Lock } from "lucide-react";
type Period = "7d" | "30d" | "90d" | "all"; type Period = "7d" | "30d" | "90d" | "all";
type Metric = "followers" | "likes" | "videoCount"; type Metric = "followers" | "likes" | "videoCount" | "views";
interface Snapshot { interface Snapshot {
createdAt: string; createdAt: string;
followers: number; followers: number;
likes: number; likes: number;
videoCount: number; videoCount: number;
views: number;
} }
interface StatsChartProps { 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: "followers", label: "Followers", color: "#c084fc", gradientId: "gradFollowers" },
{ value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" }, { value: "likes", label: "Likes", color: "#f472b6", gradientId: "gradLikes" },
{ value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" }, { value: "videoCount", label: "Vidéos", color: "#60a5fa", gradientId: "gradVideos" },
{ value: "views", label: "Vues", color: "#22c55e", gradientId: "gradViews" },
]; ];
function canAccess(plan: string, required: string) { function canAccess(plan: string, required: string) {

View File

@@ -1,6 +1,7 @@
const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize"; const TIKTOK_AUTH_URL = "https://www.tiktok.com/v2/auth/authorize";
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"; 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_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_KEY = process.env.TIKTOK_CLIENT_KEY!;
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!; const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
@@ -38,7 +39,8 @@ export function getTikTokAuthUrl(state: string, codeChallenge: string): string {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_key: CLIENT_KEY, client_key: CLIENT_KEY,
response_type: "code", 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(), redirect_uri: getRedirectUri(),
state, state,
code_challenge: codeChallenge, code_challenge: codeChallenge,
@@ -115,13 +117,57 @@ export interface TikTokUserStats {
followers: number; followers: number;
likes: number; likes: number;
videoCount: number; videoCount: number;
views: number;
profileViews: number;
username: string; username: string;
displayName: string; displayName: string;
avatarUrl: 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> { export async function fetchUserStats(accessToken: string, openId: string): Promise<TikTokUserStats> {
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 url = `${TIKTOK_USER_INFO_URL}?fields=${fields}`;
const res = await fetch(url, { const res = await fetch(url, {
@@ -137,11 +183,27 @@ export async function fetchUserStats(accessToken: string, openId: string): Promi
} }
const user = data.data?.user ?? {}; 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 { return {
followers: user.follower_count ?? 0, followers: user.follower_count ?? 0,
likes: user.likes_count ?? 0, likes: user.likes_count ?? 0,
videoCount: user.video_count ?? 0, videoCount: user.video_count ?? 0,
views,
profileViews: user.profile_view_count ?? 0,
username: openId, username: openId,
displayName: user.display_name ?? "", displayName: user.display_name ?? "",
avatarUrl: user.avatar_url ?? "", avatarUrl: user.avatar_url ?? "",

View File

@@ -17,6 +17,7 @@ const prisma = new PrismaClient({ adapter });
const TIKTOK_TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/"; 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_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_KEY = process.env.TIKTOK_CLIENT_KEY!;
const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!; const CLIENT_SECRET = process.env.TIKTOK_CLIENT_SECRET!;
const INTERVAL_MS = 60 * 60 * 1000; // 1 heure const INTERVAL_MS = 60 * 60 * 1000; // 1 heure
@@ -57,7 +58,7 @@ async function refreshTikTokToken(refreshTokenStr: string) {
} }
async function fetchTikTokStats(accessToken: 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}`, { const res = await fetch(`${TIKTOK_USER_INFO_URL}?fields=${fields}`, {
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); });
@@ -68,14 +69,66 @@ async function fetchTikTokStats(accessToken: string) {
} }
const user = data.data?.user ?? {}; 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 { return {
followers: (user.follower_count ?? 0) as number, followers: (user.follower_count ?? 0) as number,
likes: (user.likes_count ?? 0) as number, likes: (user.likes_count ?? 0) as number,
videoCount: (user.video_count ?? 0) as number, videoCount: (user.video_count ?? 0) as number,
views,
displayName: (user.display_name ?? "") as string, displayName: (user.display_name ?? "") as 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 ?? "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 ────────────────────────────────────────────────── // ── Core job ──────────────────────────────────────────────────
async function runSnapshots() { async function runSnapshots() {
@@ -136,11 +189,11 @@ async function runSnapshots() {
followers: stats.followers, followers: stats.followers,
likes: stats.likes, likes: stats.likes,
videoCount: stats.videoCount, 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) { } catch (err) {
console.error(`[worker] ✗ erreur userId=${userId}:`, err); console.error(`[worker] ✗ erreur userId=${userId}:`, err);