From d81938060bb21bc6ac3180c2ed3175a33da1fd12 Mon Sep 17 00:00:00 2001 From: knuthtimo-lab Date: Thu, 9 Jul 2026 17:03:49 +0200 Subject: [PATCH] Bild Carousel V2 --- greenlns-landing/docker-compose.yml | 3 + server/index.js | 100 +++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/greenlns-landing/docker-compose.yml b/greenlns-landing/docker-compose.yml index 51951bc..50fe5f8 100644 --- a/greenlns-landing/docker-compose.yml +++ b/greenlns-landing/docker-compose.yml @@ -49,6 +49,9 @@ services: DISCORD_WEBHOOK_DOWNLOADS_URL: ${DISCORD_WEBHOOK_DOWNLOADS_URL:-} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} PLANT_IMPORT_ADMIN_KEY: ${PLANT_IMPORT_ADMIN_KEY:-} + TIKTOK_CLIENT_KEY: ${TIKTOK_CLIENT_KEY:-} + TIKTOK_CLIENT_SECRET: ${TIKTOK_CLIENT_SECRET:-} + TIKTOK_REDIRECT_URI: ${TIKTOK_REDIRECT_URI:-https://greenlenspro.com/api/tiktok/callback} depends_on: postgres: condition: service_healthy diff --git a/server/index.js b/server/index.js index 0bf1770..c6f65bd 100644 --- a/server/index.js +++ b/server/index.js @@ -582,6 +582,10 @@ app.get('/', (_request, response) => { 'GET /api/tiktok/callback', 'GET /api/tiktok/status', 'GET /api/tiktok/token', + 'POST /api/tiktok/upload/video', + 'POST /api/tiktok/upload/photo', + 'GET /api/tiktok/upload/status', + 'GET /api/tiktok/analytics', ], }); }); @@ -1219,7 +1223,7 @@ app.get('/api/tiktok/connect', (request, response) => { 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); @@ -1537,6 +1541,100 @@ app.get('/api/tiktok/upload/status', async (request, response) => { } }); +// ─── TikTok Analytics ─────────────────────────────────────────────────────── +// 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 TIKTOK_USER_FIELDS = 'display_name,follower_count,following_count,likes_count,video_count'; +const TIKTOK_VIDEO_FIELDS = 'id,title,video_description,duration,create_time,share_url,view_count,like_count,comment_count,share_count'; +const TIKTOK_VIDEO_PAGE_SIZE = 20; // Display API maximum per page + +const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +const summarizeTiktokVideos = (videos) => { + 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 = enriched.map((video) => Number(video.view_count) || 0).sort((a, b) => a - b); + const total = (key) => enriched.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 }; +}; + +app.get('/api/tiktok/analytics', async (request, response) => { + try { + if (!isAuthorizedAdminNavigation(request)) { + return response.status(401).json({ code: 'UNAUTHORIZED', message: 'Invalid or missing admin key.' }); + } + + const maxVideos = Math.min(Math.max(Number(request.query.max_videos) || 50, 1), 200); + + const userResult = await tiktokApi( + `https://open.tiktokapis.com/v2/user/info/?fields=${TIKTOK_USER_FIELDS}`, + { method: 'GET' }, + ); + + const videos = []; + let cursor; + let hasMore = true; + while (hasMore && videos.length < maxVideos) { + const pageBody = { max_count: Math.min(TIKTOK_VIDEO_PAGE_SIZE, maxVideos - videos.length) }; + if (cursor) pageBody.cursor = cursor; + + const page = await tiktokApi( + `https://open.tiktokapis.com/v2/video/list/?fields=${TIKTOK_VIDEO_FIELDS}`, + { method: 'POST', body: Buffer.from(JSON.stringify(pageBody)) }, + ); + + const pageVideos = Array.isArray(page?.data?.videos) ? page.data.videos : []; + videos.push(...pageVideos); + cursor = page?.data?.cursor; + hasMore = Boolean(page?.data?.has_more) && pageVideos.length > 0; + } + + const { videos: enrichedVideos, summary } = summarizeTiktokVideos(videos); + + response.status(200).json({ + user: userResult?.data?.user || null, + summary, + videos: enrichedVideos, + }); + } catch (error) { + const payload = toApiErrorPayload(error); + response.status(payload.status).json(payload.body); + } +}); + // ─── Startup ─────────────────────────────────────────────────────────────── app.delete('/auth/account', async (request, response) => {