/** * Snapshot Worker * Tourne toutes les heures, indépendamment de Next.js. * Pour chaque user avec un token TikTok valide : * 1. Rafraîchit le token si nécessaire * 2. Récupère les stats TikTok * 3. Sauvegarde un snapshot en base (max 1 par heure par compte) */ import { PrismaClient } from "../app/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; import { Pool } from "pg"; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const adapter = new PrismaPg(pool); 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 const DEDUP_WINDOW_MS = 55 * 60 * 1000; // 55 min — évite les doublons si restart // ── Graceful shutdown ───────────────────────────────────────── async function shutdown() { console.log("[worker] arrêt propre..."); await prisma.$disconnect(); await pool.end(); process.exit(0); } process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); // ── TikTok helpers ──────────────────────────────────────────── async function refreshTikTokToken(refreshTokenStr: string) { const body = new URLSearchParams({ client_key: CLIENT_KEY, client_secret: CLIENT_SECRET, grant_type: "refresh_token", refresh_token: refreshTokenStr, }); 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 ?? "Refresh failed"); } return data as { access_token: string; refresh_token: string; expires_in: number }; } async function fetchTikTokStats(accessToken: string) { 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}` }, }); const data = await res.json(); if (!res.ok || data.error?.code !== "ok") { throw new Error(data.error?.message ?? "Stats fetch failed"); } 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() { console.log(`[worker] ${new Date().toISOString()} — début du run`); const tokens = await prisma.tikTokToken.findMany({ include: { user: { include: { accounts: { where: { platform: "tiktok" } } } } }, }); console.log(`[worker] ${tokens.length} compte(s) TikTok à traiter`); for (const token of tokens) { const { userId, openId } = token; let { accessToken, refreshToken: rt, expiresAt } = token; try { // 1. Refresh si nécessaire if (expiresAt.getTime() - Date.now() < 60_000) { console.log(`[worker] refresh token userId=${userId}`); const refreshed = await refreshTikTokToken(rt); await prisma.tikTokToken.update({ where: { userId }, data: { accessToken: refreshed.access_token, refreshToken: refreshed.refresh_token, expiresAt: new Date(Date.now() + refreshed.expires_in * 1000), }, }); accessToken = refreshed.access_token; } // 2. Upsert TrackedAccount let account = token.user.accounts[0] ?? null; if (!account) { const stats0 = await fetchTikTokStats(accessToken); account = await prisma.trackedAccount.create({ data: { userId, platform: "tiktok", username: stats0.displayName || openId, accountId: openId }, }); } // 3. Déduplication — skip si snapshot < 55 min const lastSnapshot = await prisma.snapshot.findFirst({ where: { accountId: account.id }, orderBy: { createdAt: "desc" }, }); if (lastSnapshot && Date.now() - lastSnapshot.createdAt.getTime() < DEDUP_WINDOW_MS) { console.log(`[worker] skip userId=${userId} — snapshot trop récent (${Math.round((Date.now() - lastSnapshot.createdAt.getTime()) / 60_000)}min)`); continue; } // 4. Fetch stats const stats = await fetchTikTokStats(accessToken); // 5. Sauvegarde snapshot await prisma.snapshot.create({ data: { accountId: account.id, followers: stats.followers, likes: stats.likes, videoCount: stats.videoCount, views: stats.views, }, }); 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); // Non bloquant — on continue avec le user suivant } } console.log(`[worker] ${new Date().toISOString()} — run terminé`); } // ── Loop principale ─────────────────────────────────────────── async function main() { console.log(`[worker] démarrage — intervalle ${INTERVAL_MS / 60_000}min`); // Run immédiat au démarrage await runSnapshots().catch(err => console.error("[worker] erreur:", err)); // Puis toutes les heures setInterval(() => { runSnapshots().catch(err => console.error("[worker] erreur:", err)); }, INTERVAL_MS); } main().catch(err => { console.error("[worker] crash fatal:", err); process.exit(1); });