feat: post analytics
This commit is contained in:
parent
99889d59e2
commit
5cca81e002
|
|
@ -3,11 +3,15 @@ import { Organization } from '@prisma/client';
|
|||
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
|
||||
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
|
||||
|
||||
@ApiTags('Analytics')
|
||||
@Controller('/analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private _integrationService: IntegrationService) {}
|
||||
constructor(
|
||||
private _integrationService: IntegrationService,
|
||||
private _postsService: PostsService
|
||||
) {}
|
||||
|
||||
@Get('/:integration')
|
||||
async getIntegration(
|
||||
|
|
@ -17,4 +21,13 @@ export class AnalyticsController {
|
|||
) {
|
||||
return this._integrationService.checkAnalytics(org, integration, date);
|
||||
}
|
||||
|
||||
@Get('/post/:postId')
|
||||
async getPostAnalytics(
|
||||
@GetOrgFromRequest() org: Organization,
|
||||
@Param('postId') postId: string,
|
||||
@Query('date') date: string
|
||||
) {
|
||||
return this._postsService.checkPostAnalytics(org.id, postId, +date);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -876,7 +876,7 @@ const CalendarItem: FC<{
|
|||
>
|
||||
<Preview />
|
||||
</div>{' '}
|
||||
{post.integration.providerIdentifier === 'x' && disableXAnalytics ? (
|
||||
{((post.integration.providerIdentifier === 'x' && disableXAnalytics) || !post.releaseId) ? (
|
||||
<></>
|
||||
) : (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,36 +1,138 @@
|
|||
import React, { FC, Fragment, useCallback } from 'react';
|
||||
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
||||
import React, { FC, Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { ChartSocial } from '@gitroom/frontend/components/analytics/chart-social';
|
||||
import { Select } from '@gitroom/react/form/select';
|
||||
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
|
||||
|
||||
interface AnalyticsData {
|
||||
label: string;
|
||||
data: Array<{ total: number; date: string }>;
|
||||
percentageChange: number;
|
||||
average?: boolean;
|
||||
}
|
||||
|
||||
export const StatisticsModal: FC<{
|
||||
postId: string;
|
||||
}> = (props) => {
|
||||
const { postId } = props;
|
||||
const modals = useModals();
|
||||
const t = useT();
|
||||
const fetch = useFetch();
|
||||
const [dateRange, setDateRange] = useState(7);
|
||||
|
||||
const loadStatistics = useCallback(async () => {
|
||||
return (await fetch(`/posts/${postId}/statistics`)).json();
|
||||
}, [postId]);
|
||||
const closeAll = useCallback(() => {
|
||||
modals.closeAll();
|
||||
}, []);
|
||||
const { data, isLoading } = useSWR(
|
||||
}, [postId, fetch]);
|
||||
|
||||
const loadPostAnalytics = useCallback(async () => {
|
||||
return (await fetch(`/analytics/post/${postId}?date=${dateRange}`)).json();
|
||||
}, [postId, dateRange, fetch]);
|
||||
|
||||
const { data: statisticsData, isLoading: isLoadingStatistics } = useSWR(
|
||||
`/posts/${postId}/statistics`,
|
||||
loadStatistics
|
||||
);
|
||||
|
||||
const { data: analyticsData, isLoading: isLoadingAnalytics } = useSWR(
|
||||
`/analytics/post/${postId}?date=${dateRange}`,
|
||||
loadPostAnalytics,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
}
|
||||
);
|
||||
|
||||
const dateOptions = useMemo(() => {
|
||||
return [
|
||||
{ key: 7, value: t('7_days', '7 Days') },
|
||||
{ key: 30, value: t('30_days', '30 Days') },
|
||||
{ key: 90, value: t('90_days', '90 Days') },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!analyticsData || !Array.isArray(analyticsData)) return [];
|
||||
return analyticsData.map((p: AnalyticsData) => {
|
||||
const value =
|
||||
(p?.data?.reduce((acc: number, curr: any) => acc + Number(curr.total), 0) || 0) /
|
||||
(p.average ? p.data.length : 1);
|
||||
if (p.average) {
|
||||
return value.toFixed(2) + '%';
|
||||
}
|
||||
return Math.round(value);
|
||||
});
|
||||
}, [analyticsData]);
|
||||
|
||||
const isLoading = isLoadingStatistics || isLoadingAnalytics;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative min-h-[200px]">
|
||||
{isLoading ? (
|
||||
<div>{t('loading', 'Loading')}</div>
|
||||
<div className="flex items-center justify-center py-[40px]">
|
||||
<LoadingComponent />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data.clicks.length === 0 ? (
|
||||
'No Results'
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 mt-[20px]">
|
||||
<div className="flex flex-col gap-[24px]">
|
||||
{/* Post Analytics Section */}
|
||||
{analyticsData && Array.isArray(analyticsData) && analyticsData.length > 0 && (
|
||||
<div className="flex flex-col gap-[14px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[18px] font-[500]">
|
||||
{t('post_analytics', 'Post Analytics')}
|
||||
</h3>
|
||||
<div className="max-w-[150px]">
|
||||
<Select
|
||||
label=""
|
||||
name="date"
|
||||
disableForm={true}
|
||||
hideErrors={true}
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(+e.target.value)}
|
||||
>
|
||||
{dateOptions.map((option) => (
|
||||
<option key={option.key} value={option.key}>
|
||||
{option.value}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-[20px]">
|
||||
{analyticsData.map((p: AnalyticsData, index: number) => (
|
||||
<div key={`analytics-${index}`} className="flex">
|
||||
<div className="flex-1 bg-newTableHeader rounded-[8px] py-[10px] px-[16px] gap-[10px] flex flex-col">
|
||||
<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 data={p.data} key={`chart-${index}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[50px] leading-[60px]">{totals[index]}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Short Links Statistics Section */}
|
||||
<div className="flex flex-col gap-[14px]">
|
||||
<h3 className="text-[18px] font-[500]">
|
||||
{t('short_links_statistics', 'Short Links Statistics')}
|
||||
</h3>
|
||||
{statisticsData?.clicks?.length === 0 ? (
|
||||
<div className="text-gray-400">
|
||||
{t('no_short_link_results', 'No short link results')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="bg-forth p-[4px] rounded-tl-lg">
|
||||
{t('short_link', 'Short Link')}
|
||||
</div>
|
||||
|
|
@ -40,7 +142,7 @@ export const StatisticsModal: FC<{
|
|||
<div className="bg-forth p-[4px] rounded-tr-lg">
|
||||
{t('clicks', 'Clicks')}
|
||||
</div>
|
||||
{data.clicks.map((p: any) => (
|
||||
{statisticsData?.clicks?.map((p: any) => (
|
||||
<Fragment key={p.short}>
|
||||
<div className="p-[4px] py-[10px] bg-customColor6">
|
||||
{p.short}
|
||||
|
|
@ -54,9 +156,17 @@ export const StatisticsModal: FC<{
|
|||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No analytics available message */}
|
||||
{(!analyticsData || !Array.isArray(analyticsData) || analyticsData.length === 0) &&
|
||||
(!statisticsData?.clicks || statisticsData.clicks.length === 0) && (
|
||||
<div className="text-center text-gray-400 py-[20px]">
|
||||
{t('no_statistics_available', 'No statistics available for this post')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -168,8 +168,7 @@ export class PostsRepository {
|
|||
content: true,
|
||||
publishDate: true,
|
||||
releaseURL: true,
|
||||
submittedForOrganizationId: true,
|
||||
submittedForOrderId: true,
|
||||
releaseId: true,
|
||||
state: true,
|
||||
intervalInDays: true,
|
||||
group: true,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ import {
|
|||
organizationId,
|
||||
postId as postIdSearchParam,
|
||||
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
|
||||
import { AnalyticsData } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
|
||||
import { timer } from '@gitroom/helpers/utils/timer';
|
||||
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
|
||||
import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract';
|
||||
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
|
||||
|
||||
type PostWithConditionals = Post & {
|
||||
integration?: Integration;
|
||||
|
|
@ -46,7 +51,8 @@ export class PostsService {
|
|||
private _mediaService: MediaService,
|
||||
private _shortLinkService: ShortLinkService,
|
||||
private _openaiService: OpenaiService,
|
||||
private _temporalService: TemporalService
|
||||
private _temporalService: TemporalService,
|
||||
private _refreshIntegrationService: RefreshIntegrationService
|
||||
) {}
|
||||
|
||||
searchForMissingThreeHoursPosts() {
|
||||
|
|
@ -57,6 +63,85 @@ export class PostsService {
|
|||
return this._postRepository.updatePost(id, postId, releaseURL);
|
||||
}
|
||||
|
||||
async checkPostAnalytics(
|
||||
orgId: string,
|
||||
postId: string,
|
||||
date: number,
|
||||
forceRefresh = false
|
||||
): Promise<AnalyticsData[]> {
|
||||
const post = await this._postRepository.getPostById(postId, orgId);
|
||||
if (!post || !post.releaseId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const integrationProvider = this._integrationManager.getSocialIntegration(
|
||||
post.integration.providerIdentifier
|
||||
);
|
||||
|
||||
if (!integrationProvider.postAnalytics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const getIntegration = post.integration!;
|
||||
|
||||
if (
|
||||
dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) ||
|
||||
forceRefresh
|
||||
) {
|
||||
const data = await this._refreshIntegrationService.refresh(
|
||||
getIntegration
|
||||
);
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { accessToken } = data;
|
||||
|
||||
if (accessToken) {
|
||||
getIntegration.token = accessToken;
|
||||
|
||||
if (integrationProvider.refreshWait) {
|
||||
await timer(10000);
|
||||
}
|
||||
} else {
|
||||
await this._integrationService.disconnectChannel(orgId, getIntegration);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const getIntegrationData = await ioRedis.get(
|
||||
`integration:${orgId}:${post.id}:${date}`
|
||||
);
|
||||
if (getIntegrationData) {
|
||||
return JSON.parse(getIntegrationData);
|
||||
}
|
||||
|
||||
try {
|
||||
const loadAnalytics = await integrationProvider.postAnalytics(
|
||||
getIntegration.internalId,
|
||||
getIntegration.token,
|
||||
post.releaseId,
|
||||
date
|
||||
);
|
||||
await ioRedis.set(
|
||||
`integration:${orgId}:${post.id}:${date}`,
|
||||
JSON.stringify(loadAnalytics),
|
||||
'EX',
|
||||
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
|
||||
? 1
|
||||
: 3600
|
||||
);
|
||||
return loadAnalytics;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
if (e instanceof RefreshToken) {
|
||||
return this.checkPostAnalytics(orgId, postId, date, true);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getStatistics(orgId: string, id: string) {
|
||||
const getPost = await this.getPostsRecursively(id, true, orgId, true);
|
||||
const content = getPost.map((p) => p.content);
|
||||
|
|
|
|||
|
|
@ -187,4 +187,14 @@ export class DribbbleProvider extends SocialAbstract implements SocialProvider {
|
|||
): Promise<AnalyticsData[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
// Dribbble doesn't provide detailed post-level analytics via their API
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,4 +462,79 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
|
|||
})) || []
|
||||
);
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
try {
|
||||
// Fetch post insights from Facebook Graph API
|
||||
const { data } = await (
|
||||
await this.fetch(
|
||||
`https://graph.facebook.com/v20.0/${postId}/insights?metric=post_impressions,post_impressions_unique,post_engaged_users,post_clicks,post_reactions_by_type_total&access_token=${accessToken}`
|
||||
)
|
||||
).json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: AnalyticsData[] = [];
|
||||
|
||||
for (const metric of data) {
|
||||
const value = metric.values?.[0]?.value;
|
||||
if (value === undefined) continue;
|
||||
|
||||
let label = '';
|
||||
let total = '';
|
||||
|
||||
switch (metric.name) {
|
||||
case 'post_impressions':
|
||||
label = 'Impressions';
|
||||
total = String(value);
|
||||
break;
|
||||
case 'post_impressions_unique':
|
||||
label = 'Reach';
|
||||
total = String(value);
|
||||
break;
|
||||
case 'post_engaged_users':
|
||||
label = 'Engaged Users';
|
||||
total = String(value);
|
||||
break;
|
||||
case 'post_clicks':
|
||||
label = 'Clicks';
|
||||
total = String(value);
|
||||
break;
|
||||
case 'post_reactions_by_type_total':
|
||||
// This returns an object with reaction types
|
||||
if (typeof value === 'object') {
|
||||
const totalReactions = Object.values(value as Record<string, number>).reduce(
|
||||
(sum: number, v: number) => sum + v,
|
||||
0
|
||||
);
|
||||
label = 'Reactions';
|
||||
total = String(totalReactions);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (label) {
|
||||
result.push({
|
||||
label,
|
||||
percentageChange: 0,
|
||||
data: [{ total, date: today }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error fetching Facebook post analytics:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -560,4 +560,15 @@ export class GmbProvider extends SocialAbstract implements SocialProvider {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
// Google My Business local posts don't have detailed individual post analytics
|
||||
// The API focuses on location-level metrics rather than post-level metrics
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -741,4 +741,73 @@ export class InstagramProvider
|
|||
)}&access_token=${accessToken}`
|
||||
);
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number,
|
||||
type = 'graph.facebook.com'
|
||||
): Promise<AnalyticsData[]> {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
try {
|
||||
// Fetch media insights from Instagram Graph API
|
||||
const { data } = await (
|
||||
await this.fetch(
|
||||
`https://${type}/v21.0/${postId}/insights?metric=impressions,reach,engagement,saved,likes,comments,shares&access_token=${accessToken}`
|
||||
)
|
||||
).json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: AnalyticsData[] = [];
|
||||
|
||||
for (const metric of data) {
|
||||
const value = metric.values?.[0]?.value;
|
||||
if (value === undefined) continue;
|
||||
|
||||
let label = '';
|
||||
|
||||
switch (metric.name) {
|
||||
case 'impressions':
|
||||
label = 'Impressions';
|
||||
break;
|
||||
case 'reach':
|
||||
label = 'Reach';
|
||||
break;
|
||||
case 'engagement':
|
||||
label = 'Engagement';
|
||||
break;
|
||||
case 'saved':
|
||||
label = 'Saves';
|
||||
break;
|
||||
case 'likes':
|
||||
label = 'Likes';
|
||||
break;
|
||||
case 'comments':
|
||||
label = 'Comments';
|
||||
break;
|
||||
case 'shares':
|
||||
label = 'Shares';
|
||||
break;
|
||||
}
|
||||
|
||||
if (label) {
|
||||
result.push({
|
||||
label,
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(value), date: today }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error fetching Instagram post analytics:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,4 +193,19 @@ export class InstagramStandaloneProvider
|
|||
'graph.instagram.com'
|
||||
);
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
) {
|
||||
return instagramProvider.postAnalytics(
|
||||
integrationId,
|
||||
accessToken,
|
||||
postId,
|
||||
date,
|
||||
'graph.instagram.com'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,6 +417,132 @@ export class LinkedinPageProvider
|
|||
}));
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
const endDate = dayjs().unix() * 1000;
|
||||
const startDate = dayjs().subtract(date, 'days').unix() * 1000;
|
||||
|
||||
// Fetch share statistics for the specific post
|
||||
const shareStatsUrl = `https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(
|
||||
`urn:li:organization:${integrationId}`
|
||||
)}&shares=List(${encodeURIComponent(postId)})&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`;
|
||||
|
||||
const { elements: shareElements }: { elements: PostShareStatElement[] } =
|
||||
await (
|
||||
await this.fetch(shareStatsUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'LinkedIn-Version': '202511',
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
},
|
||||
})
|
||||
).json();
|
||||
|
||||
// Also fetch social actions (likes, comments, shares) for the specific post
|
||||
let socialActions: SocialActionsResponse | null = null;
|
||||
try {
|
||||
const socialActionsUrl = `https://api.linkedin.com/v2/socialActions/${encodeURIComponent(
|
||||
postId
|
||||
)}`;
|
||||
socialActions = await (
|
||||
await this.fetch(socialActionsUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'LinkedIn-Version': '202511',
|
||||
'X-Restli-Protocol-Version': '2.0.0',
|
||||
},
|
||||
})
|
||||
).json();
|
||||
} catch (e) {
|
||||
// Social actions may not be available for all posts
|
||||
}
|
||||
|
||||
// Process share statistics into time series data
|
||||
const analytics = (shareElements || []).reduce(
|
||||
(all, current) => {
|
||||
if (typeof current?.totalShareStatistics !== 'undefined') {
|
||||
const dateStr = dayjs(current.timeRange.start).format('YYYY-MM-DD');
|
||||
|
||||
all['Impressions'].push({
|
||||
total: current.totalShareStatistics.impressionCount || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
all['Unique Impressions'].push({
|
||||
total: current.totalShareStatistics.uniqueImpressionsCount || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
all['Clicks'].push({
|
||||
total: current.totalShareStatistics.clickCount || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
all['Likes'].push({
|
||||
total: current.totalShareStatistics.likeCount || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
all['Comments'].push({
|
||||
total: current.totalShareStatistics.commentCount || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
all['Shares'].push({
|
||||
total: current.totalShareStatistics.shareCount || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
|
||||
all['Engagement'].push({
|
||||
total: current.totalShareStatistics.engagement || 0,
|
||||
date: dateStr,
|
||||
});
|
||||
}
|
||||
return all;
|
||||
},
|
||||
{
|
||||
Impressions: [] as { total: number; date: string }[],
|
||||
'Unique Impressions': [] as { total: number; date: string }[],
|
||||
Clicks: [] as { total: number; date: string }[],
|
||||
Likes: [] as { total: number; date: string }[],
|
||||
Comments: [] as { total: number; date: string }[],
|
||||
Shares: [] as { total: number; date: string }[],
|
||||
Engagement: [] as { total: number; date: string }[],
|
||||
}
|
||||
);
|
||||
|
||||
// If no time series data but we have social actions, create a single data point
|
||||
if (
|
||||
Object.values(analytics).every((arr) => arr.length === 0) &&
|
||||
socialActions
|
||||
) {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
analytics['Likes'].push({
|
||||
total: socialActions.likesSummary?.totalLikes || 0,
|
||||
date: today,
|
||||
});
|
||||
analytics['Comments'].push({
|
||||
total: socialActions.commentsSummary?.totalFirstLevelComments || 0,
|
||||
date: today,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out empty analytics
|
||||
const result = Object.entries(analytics)
|
||||
.filter(([_, data]) => data.length > 0)
|
||||
.map(([label, data]) => ({
|
||||
label,
|
||||
data,
|
||||
percentageChange: 0,
|
||||
}));
|
||||
|
||||
return result as any;
|
||||
}
|
||||
|
||||
@Plug({
|
||||
identifier: 'linkedin-page-autoRepostPost',
|
||||
title: 'Auto Repost Posts',
|
||||
|
|
@ -764,3 +890,30 @@ export interface TimeRange {
|
|||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// Post analytics interfaces
|
||||
export interface PostShareStatElement {
|
||||
organizationalEntity: string;
|
||||
share: string;
|
||||
totalShareStatistics: {
|
||||
uniqueImpressionsCount: number;
|
||||
shareCount: number;
|
||||
engagement: number;
|
||||
clickCount: number;
|
||||
likeCount: number;
|
||||
impressionCount: number;
|
||||
commentCount: number;
|
||||
};
|
||||
timeRange: TimeRange;
|
||||
}
|
||||
|
||||
export interface SocialActionsResponse {
|
||||
likesSummary?: {
|
||||
totalLikes: number;
|
||||
likedByCurrentUser: boolean;
|
||||
};
|
||||
commentsSummary?: {
|
||||
totalFirstLevelComments: number;
|
||||
commentsState: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,4 +358,78 @@ export class PinterestProvider
|
|||
]
|
||||
);
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const since = dayjs().subtract(date, 'day').format('YYYY-MM-DD');
|
||||
|
||||
try {
|
||||
// Fetch pin analytics from Pinterest API
|
||||
const response = await this.fetch(
|
||||
`https://api.pinterest.com/v5/pins/${postId}/analytics?start_date=${since}&end_date=${today}&metric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVE`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || !data.all) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: AnalyticsData[] = [];
|
||||
const metrics = data.all;
|
||||
|
||||
if (metrics.lifetime_metrics) {
|
||||
const lifetimeMetrics = metrics.lifetime_metrics;
|
||||
|
||||
if (lifetimeMetrics.IMPRESSION !== undefined) {
|
||||
result.push({
|
||||
label: 'Impressions',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(lifetimeMetrics.IMPRESSION), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (lifetimeMetrics.PIN_CLICK !== undefined) {
|
||||
result.push({
|
||||
label: 'Pin Clicks',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(lifetimeMetrics.PIN_CLICK), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (lifetimeMetrics.OUTBOUND_CLICK !== undefined) {
|
||||
result.push({
|
||||
label: 'Outbound Clicks',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(lifetimeMetrics.OUTBOUND_CLICK), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (lifetimeMetrics.SAVE !== undefined) {
|
||||
result.push({
|
||||
label: 'Saves',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(lifetimeMetrics.SAVE), date: today }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error fetching Pinterest post analytics:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export interface IAuthenticator {
|
|||
accessToken: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]>;
|
||||
postAnalytics?(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
fromDate: number,
|
||||
): Promise<AnalyticsData[]>;
|
||||
changeNickname?(
|
||||
id: string,
|
||||
accessToken: string,
|
||||
|
|
@ -46,6 +52,7 @@ export interface AnalyticsData {
|
|||
percentageChange: number;
|
||||
}
|
||||
|
||||
|
||||
export type GenerateAuthUrlResponse = {
|
||||
url: string;
|
||||
codeVerifier: string;
|
||||
|
|
|
|||
|
|
@ -523,6 +523,68 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
|
|||
return false;
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
try {
|
||||
// Fetch thread insights from Threads API
|
||||
const { data } = await (
|
||||
await this.fetch(
|
||||
`https://graph.threads.net/v1.0/${postId}/insights?metric=views,likes,replies,reposts,quotes&access_token=${accessToken}`
|
||||
)
|
||||
).json();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: AnalyticsData[] = [];
|
||||
|
||||
for (const metric of data) {
|
||||
const value = metric.values?.[0]?.value ?? metric.total_value?.value;
|
||||
if (value === undefined) continue;
|
||||
|
||||
let label = '';
|
||||
|
||||
switch (metric.name) {
|
||||
case 'views':
|
||||
label = 'Views';
|
||||
break;
|
||||
case 'likes':
|
||||
label = 'Likes';
|
||||
break;
|
||||
case 'replies':
|
||||
label = 'Replies';
|
||||
break;
|
||||
case 'reposts':
|
||||
label = 'Reposts';
|
||||
break;
|
||||
case 'quotes':
|
||||
label = 'Quotes';
|
||||
break;
|
||||
}
|
||||
|
||||
if (label) {
|
||||
result.push({
|
||||
label,
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(value), date: today }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error fetching Threads post analytics:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// override async mention(
|
||||
// token: string,
|
||||
// data: { query: string },
|
||||
|
|
|
|||
|
|
@ -590,6 +590,96 @@ export class XProvider extends SocialAbstract implements SocialProvider {
|
|||
return [];
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
if (process.env.DISABLE_X_ANALYTICS) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
|
||||
const client = new TwitterApi({
|
||||
appKey: process.env.X_API_KEY!,
|
||||
appSecret: process.env.X_API_SECRET!,
|
||||
accessToken: accessTokenSplit,
|
||||
accessSecret: accessSecretSplit,
|
||||
});
|
||||
|
||||
try {
|
||||
// Fetch the specific tweet with public metrics
|
||||
const tweet = await client.v2.singleTweet(postId, {
|
||||
'tweet.fields': ['public_metrics', 'created_at'],
|
||||
});
|
||||
|
||||
if (!tweet?.data?.public_metrics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const metrics = tweet.data.public_metrics;
|
||||
|
||||
const result: AnalyticsData[] = [];
|
||||
|
||||
if (metrics.impression_count !== undefined) {
|
||||
result.push({
|
||||
label: 'Impressions',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(metrics.impression_count), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.like_count !== undefined) {
|
||||
result.push({
|
||||
label: 'Likes',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(metrics.like_count), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.retweet_count !== undefined) {
|
||||
result.push({
|
||||
label: 'Retweets',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(metrics.retweet_count), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.reply_count !== undefined) {
|
||||
result.push({
|
||||
label: 'Replies',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(metrics.reply_count), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.quote_count !== undefined) {
|
||||
result.push({
|
||||
label: 'Quotes',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(metrics.quote_count), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.bookmark_count !== undefined) {
|
||||
result.push({
|
||||
label: 'Bookmarks',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(metrics.bookmark_count), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.log('Error fetching X post analytics:', err);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
override async mention(token: string, d: { query: string }) {
|
||||
const [accessTokenSplit, accessSecretSplit] = token.split(':');
|
||||
const client = new TwitterApi({
|
||||
|
|
|
|||
|
|
@ -449,4 +449,71 @@ export class YoutubeProvider extends SocialAbstract implements SocialProvider {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async postAnalytics(
|
||||
integrationId: string,
|
||||
accessToken: string,
|
||||
postId: string,
|
||||
date: number
|
||||
): Promise<AnalyticsData[]> {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
try {
|
||||
const { client, youtube } = clientAndYoutube();
|
||||
client.setCredentials({ access_token: accessToken });
|
||||
const youtubeClient = youtube(client);
|
||||
|
||||
// Fetch video statistics
|
||||
const response = await youtubeClient.videos.list({
|
||||
part: ['statistics', 'snippet'],
|
||||
id: [postId],
|
||||
});
|
||||
|
||||
const video = response.data.items?.[0];
|
||||
|
||||
if (!video || !video.statistics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stats = video.statistics;
|
||||
const result: AnalyticsData[] = [];
|
||||
|
||||
if (stats.viewCount !== undefined) {
|
||||
result.push({
|
||||
label: 'Views',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(stats.viewCount), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (stats.likeCount !== undefined) {
|
||||
result.push({
|
||||
label: 'Likes',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(stats.likeCount), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (stats.commentCount !== undefined) {
|
||||
result.push({
|
||||
label: 'Comments',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(stats.commentCount), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
if (stats.favoriteCount !== undefined) {
|
||||
result.push({
|
||||
label: 'Favorites',
|
||||
percentageChange: 0,
|
||||
data: [{ total: String(stats.favoriteCount), date: today }],
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('Error fetching YouTube post analytics:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue