Bild Carousel V2
This commit is contained in:
@@ -55,6 +55,14 @@
|
|||||||
- GreenLens should also remain **upload-only by default** unless Timo explicitly asks for a different behavior.
|
- 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.
|
- 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=<TIKTOK_ADMIN_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
|
## Refresh Behavior
|
||||||
- Meta user/page tokens werden automatisch refreshed durch `python C:\Users\timo\Documents\meta_token_refresh.py`.
|
- Meta user/page tokens werden automatisch refreshed durch `python C:\Users\timo\Documents\meta_token_refresh.py`.
|
||||||
- QRMaster Page-Auswahl erzwingt Page-ID `884792004727212`.
|
- QRMaster Page-Auswahl erzwingt Page-ID `884792004727212`.
|
||||||
|
|||||||
118
src/app/(main)/api/tiktok/analytics/route.ts
Normal file
118
src/app/(main)/api/tiktok/analytics/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
|
const authUrl = new URL('https://www.tiktok.com/v2/auth/authorize/');
|
||||||
authUrl.searchParams.set('client_key', clientKey);
|
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('response_type', 'code');
|
||||||
authUrl.searchParams.set('redirect_uri', redirectUri);
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
authUrl.searchParams.set('state', oauthState);
|
authUrl.searchParams.set('state', oauthState);
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadUrl = initResult?.data?.upload_url as string | undefined;
|
const initData = initResult?.data as Record<string, unknown> | undefined;
|
||||||
publishId = initResult?.data?.publish_id as string | undefined;
|
const uploadUrl = initData?.upload_url as string | undefined;
|
||||||
|
publishId = initData?.publish_id as string | undefined;
|
||||||
|
|
||||||
if (!uploadUrl || !publishId) {
|
if (!uploadUrl || !publishId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -111,8 +112,9 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
publishId = initResult?.data?.publish_id as string | undefined;
|
const initData = initResult?.data as Record<string, unknown> | undefined;
|
||||||
status = (initResult?.data?.status as string) || 'INITIATED';
|
publishId = initData?.publish_id as string | undefined;
|
||||||
|
status = (initData?.status as string) || 'INITIATED';
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Unsupported JSON payload. Use videoBufferBase64 or photos.' },
|
{ error: 'Unsupported JSON payload. Use videoBufferBase64 or photos.' },
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import { db } from '@/lib/db';
|
|||||||
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state';
|
export const TIKTOK_OAUTH_STATE_COOKIE_NAME = 'tiktok_oauth_state';
|
||||||
export const TIKTOK_ACCOUNT_KEY = 'hermes-agent';
|
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
|
// Refresh when the access token expires within this window, so Hermes never
|
||||||
// receives a token that dies mid-upload.
|
// receives a token that dies mid-upload.
|
||||||
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
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() {
|
export async function getLiveTiktokAccessToken() {
|
||||||
const integration = await db.tiktokIntegration.findUnique({
|
const integration = await db.tiktokIntegration.findUnique({
|
||||||
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
where: { accountKey: TIKTOK_ACCOUNT_KEY },
|
||||||
});
|
});
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
const error = new Error('No TikTok account connected.');
|
throw new TiktokApiError('No TikTok account connected.', 404);
|
||||||
error.status = 404;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (integration.accessTokenExpiresAt.getTime() - Date.now() > REFRESH_BUFFER_MS) {
|
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 clientKey = process.env.TIKTOK_CLIENT_KEY;
|
||||||
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
const clientSecret = process.env.TIKTOK_CLIENT_SECRET;
|
||||||
if (!clientKey || !clientSecret) {
|
if (!clientKey || !clientSecret) {
|
||||||
const error = new Error('TikTok client credentials are not configured.');
|
throw new TiktokApiError('TikTok client credentials are not configured.', 500);
|
||||||
error.status = 500;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {}) {
|
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<string, string> | undefined)?.code !== 'ok') {
|
if (!res.ok || (data?.error as Record<string, string> | undefined)?.code !== 'ok') {
|
||||||
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
|
const message = (data?.error as Record<string, string> | undefined)?.message || (typeof data?.raw === 'string' ? data.raw : '') || `TikTok API error: ${res.status}`;
|
||||||
const error = new Error(message);
|
throw new TiktokApiError(message, res.status, data);
|
||||||
error.status = res.status;
|
|
||||||
error.body = data;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -125,14 +131,12 @@ export async function uploadBinaryToTiktok(uploadUrl: string, buffer: Buffer, mi
|
|||||||
'Content-Type': mimeType,
|
'Content-Type': mimeType,
|
||||||
'Content-Length': String(buffer.length),
|
'Content-Length': String(buffer.length),
|
||||||
},
|
},
|
||||||
body: buffer,
|
body: new Uint8Array(buffer),
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok && res.status !== 201) {
|
if (!res.ok && res.status !== 201) {
|
||||||
const error = new Error(`TikTok binary upload failed: ${res.status} ${text}`);
|
throw new TiktokApiError(`TikTok binary upload failed: ${res.status} ${text}`, res.status);
|
||||||
error.status = res.status;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { status: res.status, body: text };
|
return { status: res.status, body: text };
|
||||||
|
|||||||
Reference in New Issue
Block a user