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:
4Ventures
2026-09-28 14:31:12 +02:00
parent 530b5ac5ae
commit aa41594e1e
3 changed files with 356 additions and 149 deletions

View File

@@ -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' ||

View File

@@ -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',
// total_value metrics (period totals + optional per-day series)
const totalMetrics = [
'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;
let totalData: {
name?: string;
total_value?: { value?: number };
}[] = [];
{
const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, {
metric,
metric: totalMetrics.join(','),
period: 'day',
metric_type: 'total_value',
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(
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,
period: 'day',
metric_type: 'total_value',
since: sinceU,
until: untilU,
}
);
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 });
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,
};

View File

@@ -85,13 +85,147 @@ type SocialPost = {
reach?: number | null;
impressions?: number | null;
mediaType?: string | null;
mediaProductType?: string | null;
children?: {
id?: string;
mediaType?: string | null;
mediaUrl?: string | null;
thumbnailUrl?: string | null;
}[];
};
function isVideoPost(p: SocialPost): boolean {
function formatLabel(p: SocialPost): string {
const t = (p.mediaType || '').toUpperCase();
const prod = (p.mediaProductType || '').toUpperCase();
if (prod === 'REELS' || t === 'REELS' || t === 'REEL') return 'Reel';
if (t.includes('CAROUSEL') || t.includes('ALBUM')) return 'Carrusel';
if (t.includes('VIDEO')) return 'Vídeo';
if (t.includes('IMAGE') || t === 'PHOTO') return 'Imagen';
if (t === 'STORY' || prod === 'STORY') return 'Story';
return t || 'Post';
}
function isVideoSlide(mediaType?: string | null, url?: string | null): boolean {
const t = (mediaType || '').toUpperCase();
if (t.includes('VIDEO') || t === 'REELS' || t === 'REEL') return true;
const url = p.mediaUrl || '';
return /\.mp4($|\?)/i.test(url) || /video/i.test(url);
return /\.mp4($|\?)/i.test(url || '') || /video/i.test(url || '');
}
function proxiedSocialUrl(raw?: string | null): string {
if (!raw) return '';
if (raw.startsWith('/api/')) return raw;
return `/api/ad-tracker/media?url=${encodeURIComponent(raw)}`;
}
function PostMedia({ post }: { post: SocialPost }) {
const slides =
post.children && post.children.length > 0
? post.children
: [
{
id: post.id,
mediaType: post.mediaType,
mediaUrl: post.mediaUrl,
thumbnailUrl: post.thumbnailUrl,
},
];
const [idx, setIdx] = useState(0);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const slide = slides[Math.min(idx, slides.length - 1)] || slides[0];
const video = isVideoSlide(slide?.mediaType, slide?.mediaUrl);
const src = proxiedSocialUrl(slide?.mediaUrl || slide?.thumbnailUrl);
const poster = proxiedSocialUrl(slide?.thumbnailUrl);
const aspect =
dims && dims.w > 0 && dims.h > 0 ? `${dims.w} / ${dims.h}` : video ? '9 / 16' : '1 / 1';
return (
<div className="space-y-1.5">
<div
className="relative w-full bg-gray-950 overflow-hidden rounded-t-2xl"
style={{ aspectRatio: aspect, maxHeight: 520 }}
>
{src && video ? (
<video
key={src}
src={src}
poster={poster || undefined}
className="absolute inset-0 w-full h-full object-contain bg-black"
controls
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
const el = e.currentTarget;
if (el.videoWidth && el.videoHeight) {
setDims({ w: el.videoWidth, h: el.videoHeight });
}
}}
/>
) : src ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={src}
src={src}
alt=""
className="absolute inset-0 w-full h-full object-contain bg-black"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth && el.naturalHeight) {
setDims({ w: el.naturalWidth, h: el.naturalHeight });
}
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
{post.message?.slice(0, 120) || 'Sin media'}
</div>
)}
<span className="absolute top-2 left-2 text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-black/70 text-white">
{post.platform === 'instagram' ? 'IG' : 'FB'} · {formatLabel(post)}
{slides.length > 1 ? ` · ${idx + 1}/${slides.length}` : ''}
</span>
{dims ? (
<span className="absolute top-2 right-2 text-[10px] font-semibold tabular-nums px-2 py-0.5 rounded bg-black/70 text-white">
{dims.w}×{dims.h}
{dims.w && dims.h
? ` · ${(dims.w / dims.h).toFixed(2)}:1`
: ''}
</span>
) : null}
{slides.length > 1 ? (
<>
<button
type="button"
aria-label="Anterior"
className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/60 text-white text-sm"
onClick={() => {
setDims(null);
setIdx((i) => (i - 1 + slides.length) % slides.length);
}}
>
‹
</button>
<button
type="button"
aria-label="Siguiente"
className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-black/60 text-white text-sm"
onClick={() => {
setDims(null);
setIdx((i) => (i + 1) % slides.length);
}}
>
›
</button>
</>
) : null}
</div>
{dims ? (
<p className="px-3 text-[10px] text-gray-500 tabular-nums">
Tamaño original: {dims.w} × {dims.h}px
{video ? ' (vídeo)' : slides.length > 1 ? ` · slide ${idx + 1}` : ''}
</p>
) : null}
</div>
);
}
const CHANNELS: {
@@ -263,7 +397,7 @@ export default function SocialDashboard({
icon: Eye,
},
{
label: 'Impresiones/vistas IG',
label: 'Visualizaciones IG',
value: n(ig?.igInsights?.impressions),
icon: Eye,
},
@@ -324,8 +458,8 @@ export default function SocialDashboard({
color: 'bg-pink-400/80',
},
{
title: 'IG · Impresiones/vistas',
hint: 'Views / impressions',
title: 'IG · Visualizaciones',
hint: 'views (metric_type=total_value)',
series: ig?.series?.impressions,
total: ig?.igInsights?.impressions,
color: 'bg-fuchsia-400/80',
@@ -524,36 +658,13 @@ export default function SocialDashboard({
key={p.id}
className="rounded-2xl border border-gray-200 bg-white overflow-hidden flex flex-col"
>
<div className="relative aspect-square bg-gray-100">
{p.mediaUrl && isVideoPost(p) ? (
<video
src={p.mediaUrl}
poster={p.thumbnailUrl || undefined}
className="absolute inset-0 w-full h-full object-cover"
controls
playsInline
preload="metadata"
/>
) : p.mediaUrl || p.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={p.thumbnailUrl || p.mediaUrl || ''}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-xs text-gray-400 px-4 text-center">
{p.message?.slice(0, 120) || 'Sin media'}
</div>
)}
<span className="absolute top-2 left-2 text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-black/70 text-white">
{p.platform === 'instagram' ? 'IG' : 'FB'}
{p.mediaType ? ` · ${p.mediaType}` : ''}
</span>
</div>
<PostMedia post={p} />
<div className="p-3 space-y-2 flex-1 flex flex-col">
<p className="text-[11px] text-gray-500">
{fmtDate(p.createdAt)}
{p.mediaProductType
? ` · ${p.mediaProductType}`
: ''}
</p>
{p.message ? (
<p className="text-xs text-gray-800 line-clamp-3 whitespace-pre-wrap">
@@ -575,6 +686,11 @@ export default function SocialDashboard({
<Eye className="w-3 h-3" /> {n(p.reach)} alc.
</span>
)}
{p.impressions != null && (
<span className="inline-flex items-center gap-1">
<Eye className="w-3 h-3" /> {n(p.impressions)} views
</span>
)}
</div>
{p.permalink && (
<a