diff --git a/docs/automations/social-accounts-and-jobs.md b/docs/automations/social-accounts-and-jobs.md index 2cf8da0..db4cffb 100644 --- a/docs/automations/social-accounts-and-jobs.md +++ b/docs/automations/social-accounts-and-jobs.md @@ -55,6 +55,14 @@ - GreenLens should also remain **upload-only by default** unless Timo explicitly asks for a different behavior. - Do not treat a generic approval like "Go" as permission for automatic/direct TikTok publishing. +## TikTok Analytics (Display API, read-only) +- Both apps request the scopes `user.info.basic,user.info.stats,video.list,video.upload,video.publish`. +- Admin-key-protected live analytics endpoints (no data is stored, every call reads fresh from TikTok): + - QRMaster: `GET /api/tiktok/analytics?key=&max_videos=50` + - GreenLens: `GET /api/tiktok/analytics` (guarded by the plant import admin key) +- Response: account stats (followers, total likes, video count) plus per-video views/likes/comments/shares with computed `engagement_rate`, `posted_weekday_utc` and `posted_hour_utc`, and a summary block (totals, average/median views). +- The new scopes must be enabled for the app in the TikTok Developer Portal, and accounts connected before the scope change must re-authorize via `/api/tiktok/connect` — otherwise TikTok returns a scope error. + ## Refresh Behavior - Meta user/page tokens werden automatisch refreshed durch `python C:\Users\timo\Documents\meta_token_refresh.py`. - QRMaster Page-Auswahl erzwingt Page-ID `884792004727212`. diff --git a/src/app/(main)/api/tiktok/analytics/route.ts b/src/app/(main)/api/tiktok/analytics/route.ts new file mode 100644 index 0000000..f4d5bd1 --- /dev/null +++ b/src/app/(main)/api/tiktok/analytics/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { tiktokApi } from '@/lib/tiktok'; + +// Live read-only stats from the Display API. Requires the user.info.stats and +// video.list scopes — accounts connected before the scope change must +// re-authorize via /api/tiktok/connect. + +const USER_FIELDS = 'display_name,follower_count,following_count,likes_count,video_count'; +const VIDEO_FIELDS = + 'id,title,video_description,duration,create_time,share_url,view_count,like_count,comment_count,share_count'; +const VIDEO_PAGE_SIZE = 20; // Display API maximum per page + +const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +type TiktokVideo = Record; + +const isAdminRequest = (request: NextRequest) => { + const adminKey = process.env.TIKTOK_ADMIN_KEY; + if (!adminKey) return false; + const provided = + request.headers.get('x-admin-key') || request.nextUrl.searchParams.get('key'); + return provided === adminKey; +}; + +const summarizeVideos = (videos: TiktokVideo[]) => { + const enriched = videos.map((video) => { + const views = Number(video.view_count) || 0; + const likes = Number(video.like_count) || 0; + const comments = Number(video.comment_count) || 0; + const shares = Number(video.share_count) || 0; + const engagements = likes + comments + shares; + const postedAt = video.create_time ? new Date(Number(video.create_time) * 1000) : null; + + return { + ...video, + engagement_rate: views > 0 ? Number((engagements / views).toFixed(4)) : null, + posted_at: postedAt ? postedAt.toISOString() : null, + posted_weekday_utc: postedAt ? WEEKDAY_NAMES[postedAt.getUTCDay()] : null, + posted_hour_utc: postedAt ? postedAt.getUTCHours() : null, + }; + }); + + const viewCounts = videos + .map((video) => Number(video['view_count']) || 0) + .sort((a, b) => a - b); + const total = (key: string) => + videos.reduce((sum, video) => sum + (Number(video[key]) || 0), 0); + const median = viewCounts.length + ? viewCounts.length % 2 + ? viewCounts[(viewCounts.length - 1) / 2] + : (viewCounts[viewCounts.length / 2 - 1] + viewCounts[viewCounts.length / 2]) / 2 + : 0; + + const summary = { + video_count: enriched.length, + total_views: total('view_count'), + total_likes: total('like_count'), + total_comments: total('comment_count'), + total_shares: total('share_count'), + average_views: enriched.length ? Math.round(total('view_count') / enriched.length) : 0, + median_views: median, + }; + + return { videos: enriched, summary }; +}; + +export async function GET(request: NextRequest) { + if (!isAdminRequest(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const maxVideosParam = Number(request.nextUrl.searchParams.get('max_videos')); + const maxVideos = Math.min(Math.max(maxVideosParam || 50, 1), 200); + + const userResult = await tiktokApi( + `https://open.tiktokapis.com/v2/user/info/?fields=${USER_FIELDS}`, + { method: 'GET' } + ); + + const videos: TiktokVideo[] = []; + let cursor: unknown; + let hasMore = true; + while (hasMore && videos.length < maxVideos) { + const pageBody: Record = { + max_count: Math.min(VIDEO_PAGE_SIZE, maxVideos - videos.length), + }; + if (cursor) pageBody.cursor = cursor; + + const page = await tiktokApi( + `https://open.tiktokapis.com/v2/video/list/?fields=${VIDEO_FIELDS}`, + { method: 'POST', body: JSON.stringify(pageBody) } + ); + + const pageData = page?.data as Record | undefined; + const pageVideos = Array.isArray(pageData?.videos) + ? (pageData.videos as TiktokVideo[]) + : []; + videos.push(...pageVideos); + cursor = pageData?.cursor; + hasMore = Boolean(pageData?.has_more) && pageVideos.length > 0; + } + + const { videos: enrichedVideos, summary } = summarizeVideos(videos); + const userData = userResult?.data as Record | undefined; + + return NextResponse.json({ + user: userData?.user || null, + summary, + videos: enrichedVideos, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + const status = + err instanceof Error && 'status' in err ? (err as { status?: number }).status : 502; + return NextResponse.json({ error: message }, { status: status || 502 }); + } +} diff --git a/src/app/(main)/api/tiktok/connect/route.ts b/src/app/(main)/api/tiktok/connect/route.ts index e9eb88d..a1be41a 100644 --- a/src/app/(main)/api/tiktok/connect/route.ts +++ b/src/app/(main)/api/tiktok/connect/route.ts @@ -24,7 +24,7 @@ export async function GET(request: NextRequest) { const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/'); authUrl.searchParams.set('client_key', clientKey); - authUrl.searchParams.set('scope', 'user.info.basic,video.upload,video.publish'); + authUrl.searchParams.set('scope', 'user.info.basic,user.info.stats,video.list,video.upload,video.publish'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('state', oauthState); diff --git a/src/app/(main)/api/tiktok/upload/route.ts b/src/app/(main)/api/tiktok/upload/route.ts index a4c8a77..b167fb5 100644 --- a/src/app/(main)/api/tiktok/upload/route.ts +++ b/src/app/(main)/api/tiktok/upload/route.ts @@ -50,8 +50,9 @@ export async function POST(request: NextRequest) { } ); - const uploadUrl = initResult?.data?.upload_url as string | undefined; - publishId = initResult?.data?.publish_id as string | undefined; + const initData = initResult?.data as Record | undefined; + const uploadUrl = initData?.upload_url as string | undefined; + publishId = initData?.publish_id as string | undefined; if (!uploadUrl || !publishId) { return NextResponse.json( @@ -111,8 +112,9 @@ export async function POST(request: NextRequest) { } ); - publishId = initResult?.data?.publish_id as string | undefined; - status = (initResult?.data?.status as string) || 'INITIATED'; + const initData = initResult?.data as Record | undefined; + publishId = initData?.publish_id as string | undefined; + status = (initData?.status as string) || 'INITIATED'; } else { return NextResponse.json( { error: 'Unsupported JSON payload. Use videoBufferBase64 or photos.' }, diff --git a/src/lib/tiktok.ts b/src/lib/tiktok.ts index 8674039..f150f75 100644 --- a/src/lib/tiktok.ts +++ b/src/lib/tiktok.ts @@ -3,6 +3,17 @@ import { db } from '@/lib/db'; export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state'; export const TIKTOK_ACCOUNT_KEY = 'hermes-agent'; +export class TiktokApiError extends Error { + status: number; + body?: unknown; + + constructor(message: string, status = 502, body?: unknown) { + super(message); + this.status = status; + this.body = body; + } +} + // Refresh when the access token expires within this window, so Hermes never // receives a token that dies mid-upload. const REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -58,16 +69,12 @@ export async function getValidTiktokTokens() { }); } -const REFRESH_BUFFER_MS = 5 * 60 * 1000; - export async function getLiveTiktokAccessToken() { const integration = await db.tiktokIntegration.findUnique({ where: { accountKey: TIKTOK_ACCOUNT_KEY }, }); if (!integration) { - const error = new Error('No TikTok account connected.'); - error.status = 404; - throw error; + throw new TiktokApiError('No TikTok account connected.', 404); } if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) { @@ -77,12 +84,14 @@ export async function getLiveTiktokAccessToken() { const clientKey = process.env.TIKTOK_CLIENT_KEY; const clientSecret = process.env.TIKTOK_CLIENT_SECRET; if (!clientKey || !clientSecret) { - const error = new Error('TikTok client credentials are not configured.'); - error.status = 500; - throw error; + throw new TiktokApiError('TikTok client credentials are not configured.', 500); } - return getValidTiktokTokens(); + const refreshed = await getValidTiktokTokens(); + if (!refreshed) { + throw new TiktokApiError('No TikTok account connected.', 404); + } + return refreshed; } export async function tiktokApi(url: string, options: RequestInit = {}) { @@ -109,10 +118,7 @@ export async function tiktokApi(url: string, options: RequestInit = {}) { if (!res.ok || (data?.error as Record | undefined)?.code !== 'ok') { const message = (data?.error as Record | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`; - const error = new Error(message); - error.status = res.status; - error.body = data; - throw error; + throw new TiktokApiError(message, res.status, data); } return data; @@ -125,14 +131,12 @@ export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mi 'Content-Type': mimeType, 'Content-Length': String(buffer.length), }, - body: buffer, + body: new Uint8Array(buffer), }); const text = await res.text(); if (!res.ok && res.status !== 201) { - const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`); - error.status = res.status; - throw error; + throw new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status); } return { status: res.status, body: text };