feat: tiktok analytics
This commit is contained in:
parent
ee012fb021
commit
5c50e962ae
4 changed files with 293 additions and 6 deletions
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal 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.
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -91,12 +91,20 @@ export const RenderAnalytics: FC<{
|
|||
<div className="flex items-center gap-[14px]">
|
||||
<div className="text-[20px]">{p.label}</div>
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue