Bild Carousel V2
This commit is contained in:
@@ -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
|
||||
|
||||
100
server/index.js
100
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) => {
|
||||
|
||||
Reference in New Issue
Block a user