feat: tiktok analytics

This commit is contained in:
Nevo David 2026-01-30 11:06:50 +07:00
parent ee012fb021
commit 5c50e962ae
4 changed files with 293 additions and 6 deletions

61
CLAUDE.md Normal file
View file

@ -0,0 +1,61 @@
This project is Postiz, a tool to schedule social media and chat posts to 28+ channels.
You can add posts to the calendar, they will be added into a workflow and posted at the right time.
You can find things like:
- Schedule posts
- Calendar view
- Analytics
- Team management
- Media library
This project is a monorepo with a root only package.json of dependencies.
Made with PNPM.
We have 3 important folders
- apps/backend - this is where the API code is (NESTJS)
- apps/orchestrator - this is temporal, it's for background jobs (NESTJS) it contains all the workflows and activities
- apps/frontend - this is the code of the frontend (Vite ReactJS)
- /libraries contains a lot of services shared between backend and orchestrator and frontend components.
We are using only pnpm, don't use any other dependency manager.
Never install frontend components from npmjs, focus on writing native components.
The project uses tailwind 3, before writing any component look at:
- /apps/frontend/src/app/colors.scss
- /apps/frontend/src/app/global.scss
- /apps/frontend/tailwind.config.js
All the --color-custom* are deprecated, don't use them.
And check other components in the system before to get the right design.
When working on the backend we need to pass the 3 layers:
Controller >> Service >> Repository (no shortcuts)
In some cases we will have
Controller >> Mananger >> Service >> Repository.
Most of the server logic should be inside of libs/server.
The backend repository is mostly used to write controller, and import files from libs.server.
For the frontend follow this:
- Many of the UI components lives in /apps/frontend/src/components/ui
- Routing is in /apps/frontend/src/app
- Components are in /apps/frontend/src/components
- always use SWR to fetch stuff, and use "useFetch" hook from /libraries/helpers/src/utils/custom.fetch.tsx
When using SWR, each one have to be in a seperate hook and must comply with react-hooks/rules-of-hooks, never put eslint-disable-next-line on it.
It means that this is valid:
const useCommunity = () => {
return useSWR....
}
This is not valid:
const useCommunity = () => {
return {
communities: () => useSWR<CommunitiesListResponse>("communities", getCommunities),
providers: () => useSWR<ProvidersListResponse>("providers", getProviders),
};
}
- Linting of the project can run only from the root.
- Use only pnpm.

View file

@ -22,7 +22,7 @@ const allowedIntegrations = [
'instagram',
'instagram-standalone',
'linkedin-page',
// 'tiktok',
'tiktok',
'youtube',
'gmb',
'pinterest',
@ -86,6 +86,7 @@ export const PlatformAnalytics = () => {
'threads',
'gmb',
'x',
'tiktok',
].indexOf(currentIntegration.identifier) !== -1
) {
arr.push({
@ -104,6 +105,7 @@ export const PlatformAnalytics = () => {
'threads',
'gmb',
'x',
'tiktok',
].indexOf(currentIntegration.identifier) !== -1
) {
arr.push({

View file

@ -91,12 +91,20 @@ export const RenderAnalytics: FC<{
<div className="flex items-center gap-[14px]">
<div className="text-[20px]">{p.label}</div>
</div>
<div className="flex-1">
<div className="h-[156px] relative">
<ChartSocial {...p} key={`p-${index}`} />
{p.data.length > 1 ? (
<>
<div className="flex-1">
<div className="h-[156px] relative">
<ChartSocial {...p} key={`p-${index}`} />
</div>
</div>
<div className="text-[50px] leading-[60px]">{total[index]}</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center min-h-[216px]">
<div className="text-[64px] leading-[72px] font-medium">{total[index]}</div>
</div>
</div>
<div className="text-[50px] leading-[60px]">{total[index]}</div>
)}
</div>
</div>
))}

View file

@ -1,4 +1,5 @@
import {
AnalyticsData,
AuthTokenDetails,
PostDetails,
PostResponse,
@ -23,10 +24,12 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
isBetweenSteps = false;
convertToJPEG = true;
scopes = [
'video.list',
'user.info.basic',
'video.publish',
'video.upload',
'user.info.profile',
'user.info.stats',
];
override maxConcurrentJob = 1; // TikTok has strict video upload limits
dto = TikTokDto;
@ -538,4 +541,217 @@ export class TiktokProvider extends SocialAbstract implements SocialProvider {
},
];
}
async analytics(
id: string,
accessToken: string,
date: number
): Promise<AnalyticsData[]> {
const today = dayjs().format('YYYY-MM-DD');
try {
// Get user stats (follower_count, following_count, likes_count, video_count)
const userStatsResponse = await this.fetch(
'https://open.tiktokapis.com/v2/user/info/?fields=follower_count,following_count,likes_count,video_count',
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
const userStatsData = await userStatsResponse.json();
const userStats = userStatsData?.data?.user;
const result: AnalyticsData[] = [];
if (userStats) {
if (userStats.follower_count !== undefined) {
result.push({
label: 'Followers',
percentageChange: 0,
data: [{ total: String(userStats.follower_count), date: today }],
});
}
if (userStats.following_count !== undefined) {
result.push({
label: 'Following',
percentageChange: 0,
data: [{ total: String(userStats.following_count), date: today }],
});
}
if (userStats.likes_count !== undefined) {
result.push({
label: 'Total Likes',
percentageChange: 0,
data: [{ total: String(userStats.likes_count), date: today }],
});
}
if (userStats.video_count !== undefined) {
result.push({
label: 'Videos',
percentageChange: 0,
data: [{ total: String(userStats.video_count), date: today }],
});
}
}
// Get recent videos and aggregate their stats
const videoListResponse = await this.fetch(
'https://open.tiktokapis.com/v2/video/list/?fields=id',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ max_count: 20 }),
}
);
const videoListData = await videoListResponse.json();
const videos = videoListData?.data?.videos;
if (videos && videos.length > 0) {
const videoIds = videos.map((v: { id: string }) => v.id);
// Query video details to get engagement metrics
const videoQueryResponse = await this.fetch(
'https://open.tiktokapis.com/v2/video/query/?fields=id,like_count,comment_count,share_count,view_count',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
filters: { video_ids: videoIds },
}),
}
);
const videoQueryData = await videoQueryResponse.json();
const videoDetails = videoQueryData?.data?.videos;
if (videoDetails && videoDetails.length > 0) {
let totalViews = 0;
let totalLikes = 0;
let totalComments = 0;
let totalShares = 0;
for (const video of videoDetails) {
totalViews += video.view_count || 0;
totalLikes += video.like_count || 0;
totalComments += video.comment_count || 0;
totalShares += video.share_count || 0;
}
result.push({
label: 'Recent Views',
percentageChange: 0,
data: [{ total: String(totalViews), date: today }],
});
result.push({
label: 'Recent Likes',
percentageChange: 0,
data: [{ total: String(totalLikes), date: today }],
});
result.push({
label: 'Recent Comments',
percentageChange: 0,
data: [{ total: String(totalComments), date: today }],
});
result.push({
label: 'Recent Shares',
percentageChange: 0,
data: [{ total: String(totalShares), date: today }],
});
}
}
return result;
} catch (err) {
console.error('Error fetching TikTok analytics:', err);
return [];
}
}
async postAnalytics(
integrationId: string,
accessToken: string,
postId: string,
fromDate: number
): Promise<AnalyticsData[]> {
const today = dayjs().format('YYYY-MM-DD');
try {
// Query video details using the video ID
const response = await this.fetch(
'https://open.tiktokapis.com/v2/video/query/?fields=id,like_count,comment_count,share_count,view_count',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
filters: { video_ids: [postId] },
}),
}
);
const data = await response.json();
const video = data?.data?.videos?.[0];
if (!video) {
return [];
}
const result: AnalyticsData[] = [];
if (video.view_count !== undefined) {
result.push({
label: 'Views',
percentageChange: 0,
data: [{ total: String(video.view_count), date: today }],
});
}
if (video.like_count !== undefined) {
result.push({
label: 'Likes',
percentageChange: 0,
data: [{ total: String(video.like_count), date: today }],
});
}
if (video.comment_count !== undefined) {
result.push({
label: 'Comments',
percentageChange: 0,
data: [{ total: String(video.comment_count), date: today }],
});
}
if (video.share_count !== undefined) {
result.push({
label: 'Shares',
percentageChange: 0,
data: [{ total: String(video.share_count), date: today }],
});
}
return result;
} catch (err) {
console.error('Error fetching TikTok post analytics:', err);
return [];
}
}
}