Bild Carousel V2

This commit is contained in:
2026-07-09 17:02:33 +02:00
parent 2a14c84ad3
commit cc2522f7a4
5 changed files with 154 additions and 22 deletions

View File

@@ -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<string, unknown>;
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<string, unknown> = {
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<string, unknown> | 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<string, unknown> | 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 });
}
}

View File

@@ -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);

View File

@@ -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<string, unknown> | 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<string, unknown> | 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.' },