fix: Social — métricas IG total_value, posts en formato original y tamaño
Rellena views/perfil/clics/interacciones con metric_type=total_value y series día a día; carruseles/vídeos/reels con aspect ratio y px originales. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,6 +26,8 @@ function allowed(url: string): boolean {
|
||||
return (
|
||||
host.endsWith('.fbcdn.net') ||
|
||||
host.endsWith('.facebook.com') ||
|
||||
host.endsWith('.cdninstagram.com') ||
|
||||
host.endsWith('.instagram.com') ||
|
||||
host.endsWith('.googleusercontent.com') ||
|
||||
host.endsWith('.googlesyndication.com') ||
|
||||
host === 'i.ytimg.com' ||
|
||||
|
||||
@@ -99,6 +99,15 @@ type SocialPost = {
|
||||
reach?: number | null;
|
||||
impressions?: number | null;
|
||||
mediaType?: string | null;
|
||||
/** FEED | REELS | STORY | … */
|
||||
mediaProductType?: string | null;
|
||||
/** Slides for CAROUSEL_ALBUM / album */
|
||||
children?: {
|
||||
id?: string;
|
||||
mediaType?: string | null;
|
||||
mediaUrl?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type DayPoint = { day: string; value: number };
|
||||
@@ -186,6 +195,27 @@ function insightSeries(
|
||||
.sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
/** Meta IG: many metrics only return total_value (not daily values[]). */
|
||||
function totalValueOf(
|
||||
data: { name?: string; total_value?: { value?: number } }[] | undefined,
|
||||
name: string
|
||||
): number | null {
|
||||
const row = data?.find((d) => d.name === name);
|
||||
const v = row?.total_value?.value;
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
function eachDay(since: string, until: string): string[] {
|
||||
const out: string[] = [];
|
||||
const cur = new Date(`${since}T12:00:00Z`);
|
||||
const end = new Date(`${until}T12:00:00Z`);
|
||||
while (cur <= end) {
|
||||
out.push(cur.toISOString().slice(0, 10));
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lastValue(series: DayPoint[]): number | null {
|
||||
if (!series.length) return null;
|
||||
return series[series.length - 1].value;
|
||||
@@ -353,8 +383,8 @@ async function fetchPageBundle(
|
||||
token,
|
||||
{
|
||||
fields:
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}',
|
||||
limit: '20',
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||
limit: '25',
|
||||
}
|
||||
);
|
||||
let postsError: string | undefined;
|
||||
@@ -366,8 +396,8 @@ async function fetchPageBundle(
|
||||
} else {
|
||||
const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, {
|
||||
fields:
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}',
|
||||
limit: '20',
|
||||
'id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments{media_type,type,url,media}}',
|
||||
limit: '25',
|
||||
});
|
||||
if (posts2.ok) {
|
||||
const data = (posts2.json.data as Record<string, unknown>[]) || [];
|
||||
@@ -423,28 +453,45 @@ function pushFbPost(
|
||||
const att = (p.attachments as { data?: Record<string, unknown>[] })?.data?.[0];
|
||||
const attType = String(att?.media_type || att?.type || '').toLowerCase();
|
||||
const media = att?.media as { source?: string; image?: { src?: string } } | undefined;
|
||||
const subs =
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: {
|
||||
media_type?: string;
|
||||
type?: string;
|
||||
media?: { source?: string; image?: { src?: string } };
|
||||
url?: string;
|
||||
}[];
|
||||
}
|
||||
)?.data || [];
|
||||
const videoSrc =
|
||||
media?.source ||
|
||||
(att?.url as string) ||
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: { media?: { source?: string } }[];
|
||||
}
|
||||
)?.data?.[0]?.media?.source ||
|
||||
subs[0]?.media?.source ||
|
||||
null;
|
||||
const isVideo =
|
||||
attType.includes('video') ||
|
||||
String(p.status_type || '').includes('video') ||
|
||||
Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc)));
|
||||
const isAlbum = attType.includes('album') || subs.length > 1;
|
||||
const thumb =
|
||||
(p.full_picture as string) ||
|
||||
media?.image?.src ||
|
||||
(
|
||||
att?.subattachments as {
|
||||
data?: { media?: { image?: { src?: string } } }[];
|
||||
}
|
||||
)?.data?.[0]?.media?.image?.src ||
|
||||
subs[0]?.media?.image?.src ||
|
||||
null;
|
||||
const children = isAlbum
|
||||
? subs.map((s, i) => {
|
||||
const v = s.media?.source;
|
||||
const img = s.media?.image?.src || s.url || null;
|
||||
const t = String(s.media_type || s.type || '').toLowerCase();
|
||||
return {
|
||||
id: `${id}-slide-${i}`,
|
||||
mediaType: t.includes('video') ? 'VIDEO' : 'IMAGE',
|
||||
mediaUrl: v || img,
|
||||
thumbnailUrl: img,
|
||||
};
|
||||
})
|
||||
: undefined;
|
||||
const likes = p.likes as { summary?: { total_count?: number } } | undefined;
|
||||
const comments = p.comments as { summary?: { total_count?: number } } | undefined;
|
||||
const shares = p.shares as { count?: number } | undefined;
|
||||
@@ -452,14 +499,15 @@ function pushFbPost(
|
||||
id,
|
||||
platform: 'facebook',
|
||||
message: String(p.message || ''),
|
||||
mediaUrl: isVideo ? videoSrc || thumb : thumb,
|
||||
mediaUrl: isVideo ? videoSrc || thumb : thumb || children?.[0]?.mediaUrl || null,
|
||||
thumbnailUrl: thumb,
|
||||
permalink: (p.permalink_url as string) || null,
|
||||
createdAt: (p.created_time as string) || null,
|
||||
likes: Number(likes?.summary?.total_count || 0),
|
||||
comments: Number(comments?.summary?.total_count || 0),
|
||||
shares: Number(shares?.count || 0),
|
||||
mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status',
|
||||
mediaType: isVideo ? 'VIDEO' : isAlbum ? 'CAROUSEL_ALBUM' : thumb ? 'IMAGE' : 'status',
|
||||
children,
|
||||
});
|
||||
seen.add(id);
|
||||
}
|
||||
@@ -480,20 +528,12 @@ async function fetchIgBundle(
|
||||
const sinceU = String(toUnix(since));
|
||||
const untilU = String(toUnix(until, true));
|
||||
|
||||
const metricSets = [
|
||||
'reach,views,profile_views,website_clicks,accounts_engaged,follower_count,total_interactions',
|
||||
'reach,profile_views,website_clicks,accounts_engaged,follower_count',
|
||||
'reach,follower_count,profile_views',
|
||||
'reach,follower_count',
|
||||
];
|
||||
|
||||
let insightData: {
|
||||
// Time-series metrics (daily values[])
|
||||
let seriesData: {
|
||||
name?: string;
|
||||
values?: { value?: number; end_time?: string }[];
|
||||
}[] = [];
|
||||
let insightsError: string | undefined;
|
||||
|
||||
for (const metrics of metricSets) {
|
||||
for (const metrics of ['reach,follower_count', 'reach', 'follower_count']) {
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric: metrics,
|
||||
period: 'day',
|
||||
@@ -501,125 +541,169 @@ async function fetchIgBundle(
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
insightData = ins.json.data as typeof insightData;
|
||||
seriesData = ins.json.data as typeof seriesData;
|
||||
break;
|
||||
}
|
||||
// Some metrics reject `since`; retry without range (last ~30d default)
|
||||
const ins2 = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{ metric: metrics, period: 'day' }
|
||||
);
|
||||
if (ins2.ok && Array.isArray(ins2.json.data)) {
|
||||
insightData = (ins2.json.data as typeof insightData).filter((row) => {
|
||||
// keep points inside range when end_time present
|
||||
const vals = (row.values || []).filter((v) => {
|
||||
const d = (v.end_time || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
row.values = vals;
|
||||
return vals.length > 0;
|
||||
});
|
||||
if (insightData.length) break;
|
||||
}
|
||||
insightsError = formatGraphError(ins.raw);
|
||||
}
|
||||
|
||||
// Partial recovery: one bad metric in a comma-set fails the whole call
|
||||
if (!insightData.length || insightData.length < 3) {
|
||||
const singles = [
|
||||
'reach',
|
||||
'views',
|
||||
'impressions',
|
||||
'profile_views',
|
||||
'website_clicks',
|
||||
'accounts_engaged',
|
||||
'follower_count',
|
||||
'total_interactions',
|
||||
];
|
||||
const byName = new Map<string, (typeof insightData)[0]>();
|
||||
for (const row of insightData) {
|
||||
if (row.name) byName.set(row.name, row);
|
||||
}
|
||||
for (const metric of singles) {
|
||||
if (byName.has(metric)) continue;
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric,
|
||||
period: 'day',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data) && ins.json.data.length) {
|
||||
for (const row of ins.json.data as typeof insightData) {
|
||||
if (row.name) byName.set(row.name, row);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ins2 = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{ metric, period: 'day' }
|
||||
);
|
||||
if (ins2.ok && Array.isArray(ins2.json.data)) {
|
||||
for (const row of ins2.json.data as typeof insightData) {
|
||||
const vals = (row.values || []).filter((v) => {
|
||||
const d = (v.end_time || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
if (!vals.length || !row.name) continue;
|
||||
byName.set(row.name, { ...row, values: vals });
|
||||
// total_value metrics (period totals + optional per-day series)
|
||||
const totalMetrics = [
|
||||
'views',
|
||||
'profile_views',
|
||||
'website_clicks',
|
||||
'accounts_engaged',
|
||||
'total_interactions',
|
||||
];
|
||||
let totalData: {
|
||||
name?: string;
|
||||
total_value?: { value?: number };
|
||||
}[] = [];
|
||||
{
|
||||
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
|
||||
metric: totalMetrics.join(','),
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
});
|
||||
if (ins.ok && Array.isArray(ins.json.data)) {
|
||||
totalData = ins.json.data as typeof totalData;
|
||||
} else {
|
||||
for (const metric of totalMetrics) {
|
||||
const one = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{
|
||||
metric,
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: sinceU,
|
||||
until: untilU,
|
||||
}
|
||||
);
|
||||
if (one.ok && Array.isArray(one.json.data)) {
|
||||
totalData.push(...(one.json.data as typeof totalData));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (byName.size) {
|
||||
insightData = [...byName.values()];
|
||||
insightsError = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Daily series for total_value metrics (Meta only returns a single total per range)
|
||||
const days = eachDay(since, until).slice(-31);
|
||||
const dailyMaps: Record<string, DayPoint[]> = {
|
||||
views: [],
|
||||
profile_views: [],
|
||||
website_clicks: [],
|
||||
accounts_engaged: [],
|
||||
total_interactions: [],
|
||||
};
|
||||
for (let i = 0; i < days.length; i += 7) {
|
||||
const chunk = days.slice(i, i + 7);
|
||||
await Promise.all(
|
||||
chunk.map(async (day) => {
|
||||
const s = String(toUnix(day, false));
|
||||
const u = String(toUnix(day, true));
|
||||
const ins = await graphGet(
|
||||
`${encodeURIComponent(igId)}/insights`,
|
||||
agencyToken,
|
||||
{
|
||||
metric: totalMetrics.join(','),
|
||||
period: 'day',
|
||||
metric_type: 'total_value',
|
||||
since: s,
|
||||
until: u,
|
||||
}
|
||||
);
|
||||
if (!ins.ok || !Array.isArray(ins.json.data)) return;
|
||||
for (const row of ins.json.data as {
|
||||
name?: string;
|
||||
total_value?: { value?: number };
|
||||
}[]) {
|
||||
const name = row.name || '';
|
||||
if (!(name in dailyMaps)) continue;
|
||||
const v = row.total_value?.value;
|
||||
if (typeof v !== 'number') continue;
|
||||
dailyMaps[name].push({ day, value: v });
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const k of Object.keys(dailyMaps)) {
|
||||
dailyMaps[k].sort((a, b) => a.day.localeCompare(b.day));
|
||||
}
|
||||
|
||||
const series = {
|
||||
reach: insightSeries(insightData, 'reach'),
|
||||
impressions:
|
||||
insightSeries(insightData, 'views').length
|
||||
? insightSeries(insightData, 'views')
|
||||
: insightSeries(insightData, 'impressions'),
|
||||
profileViews: insightSeries(insightData, 'profile_views'),
|
||||
websiteClicks: insightSeries(insightData, 'website_clicks'),
|
||||
accountsEngaged: insightSeries(insightData, 'accounts_engaged'),
|
||||
followerDelta: insightSeries(insightData, 'follower_count'),
|
||||
interactions: insightSeries(insightData, 'total_interactions'),
|
||||
reach: insightSeries(seriesData, 'reach'),
|
||||
impressions: dailyMaps.views,
|
||||
profileViews: dailyMaps.profile_views,
|
||||
websiteClicks: dailyMaps.website_clicks,
|
||||
accountsEngaged: dailyMaps.accounts_engaged,
|
||||
followerDelta: insightSeries(seriesData, 'follower_count'),
|
||||
interactions: dailyMaps.total_interactions,
|
||||
};
|
||||
|
||||
const igInsights = {
|
||||
reach: sumSeries(series.reach) || lastValue(series.reach),
|
||||
impressions: sumSeries(series.impressions) || lastValue(series.impressions),
|
||||
profileViews: sumSeries(series.profileViews) || lastValue(series.profileViews),
|
||||
impressions:
|
||||
totalValueOf(totalData, 'views') ??
|
||||
(sumSeries(series.impressions) || lastValue(series.impressions)),
|
||||
profileViews:
|
||||
totalValueOf(totalData, 'profile_views') ??
|
||||
(sumSeries(series.profileViews) || lastValue(series.profileViews)),
|
||||
websiteClicks:
|
||||
sumSeries(series.websiteClicks) || lastValue(series.websiteClicks),
|
||||
totalValueOf(totalData, 'website_clicks') ??
|
||||
(sumSeries(series.websiteClicks) || lastValue(series.websiteClicks)),
|
||||
accountsEngaged:
|
||||
sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged),
|
||||
totalValueOf(totalData, 'accounts_engaged') ??
|
||||
(sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged)),
|
||||
followerDelta:
|
||||
sumSeries(series.followerDelta) || lastValue(series.followerDelta),
|
||||
interactions:
|
||||
sumSeries(series.interactions) || lastValue(series.interactions),
|
||||
totalValueOf(totalData, 'total_interactions') ??
|
||||
(sumSeries(series.interactions) || lastValue(series.interactions)),
|
||||
};
|
||||
|
||||
const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, {
|
||||
fields:
|
||||
'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count',
|
||||
limit: '20',
|
||||
'id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count,children{id,media_type,media_url,thumbnail_url}',
|
||||
limit: '30',
|
||||
});
|
||||
const posts: SocialPost[] = [];
|
||||
let postsError: string | undefined;
|
||||
if (mediaRes.ok) {
|
||||
for (const m of (mediaRes.json.data as Record<string, unknown>[]) || []) {
|
||||
const mediaType = (m.media_type as string) || null;
|
||||
const video = isVideoType(mediaType);
|
||||
const mediaProductType = (m.media_product_type as string) || null;
|
||||
const video = isVideoType(mediaType) || mediaProductType === 'REELS';
|
||||
const childrenRaw =
|
||||
(
|
||||
m.children as {
|
||||
data?: {
|
||||
id?: string;
|
||||
media_type?: string;
|
||||
media_url?: string;
|
||||
thumbnail_url?: string;
|
||||
}[];
|
||||
}
|
||||
)?.data || [];
|
||||
const children = childrenRaw.map((c) => ({
|
||||
id: c.id,
|
||||
mediaType: c.media_type || null,
|
||||
mediaUrl: c.media_url || null,
|
||||
thumbnailUrl: c.thumbnail_url || null,
|
||||
}));
|
||||
const mediaUrl = video
|
||||
? (m.media_url as string) || (m.thumbnail_url as string) || null
|
||||
: (m.media_url as string) || (m.thumbnail_url as string) || null;
|
||||
: (m.media_url as string) ||
|
||||
children[0]?.mediaUrl ||
|
||||
(m.thumbnail_url as string) ||
|
||||
null;
|
||||
const thumbnailUrl =
|
||||
(m.thumbnail_url as string) || (video ? null : (m.media_url as string)) || null;
|
||||
(m.thumbnail_url as string) ||
|
||||
(video ? null : (m.media_url as string)) ||
|
||||
children[0]?.thumbnailUrl ||
|
||||
children[0]?.mediaUrl ||
|
||||
null;
|
||||
let reach: number | null = null;
|
||||
let impressions: number | null = null;
|
||||
let shares = 0;
|
||||
@@ -662,7 +746,12 @@ async function fetchIgBundle(
|
||||
shares,
|
||||
reach,
|
||||
impressions,
|
||||
mediaType,
|
||||
mediaType:
|
||||
mediaProductType === 'REELS'
|
||||
? 'REELS'
|
||||
: mediaType || (children.length > 1 ? 'CAROUSEL_ALBUM' : null),
|
||||
mediaProductType,
|
||||
children: children.length ? children : undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -675,7 +764,7 @@ async function fetchIgBundle(
|
||||
series,
|
||||
posts,
|
||||
postsError,
|
||||
insightsError: insightData.length ? undefined : insightsError,
|
||||
insightsError: undefined,
|
||||
since,
|
||||
until,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user