From aa41594e1e232bb4143274469ba0f2863fce690f Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Mon, 28 Sep 2026 14:31:12 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Social=20=E2=80=94=20m=C3=A9tricas=20IG?= =?UTF-8?q?=20total=5Fvalue,=20posts=20en=20formato=20original=20y=20tama?= =?UTF-8?q?=C3=B1o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/ad-tracker/media/route.ts | 2 + src/app/api/social/route.ts | 321 ++++++++++++++++---------- src/components/SocialDashboard.tsx | 182 ++++++++++++--- 3 files changed, 356 insertions(+), 149 deletions(-) diff --git a/src/app/api/ad-tracker/media/route.ts b/src/app/api/ad-tracker/media/route.ts index e21e01a..d2753a8 100644 --- a/src/app/api/ad-tracker/media/route.ts +++ b/src/app/api/ad-tracker/media/route.ts @@ -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' || diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index 66b25ba..4c02d5a 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -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[]) || []; @@ -423,28 +453,45 @@ function pushFbPost( const att = (p.attachments as { data?: Record[] })?.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(); - 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 = { + 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[]) || []) { 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, }; diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx index 423abef..ba020e6 100644 --- a/src/components/SocialDashboard.tsx +++ b/src/components/SocialDashboard.tsx @@ -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 ( +
+
+ {src && video ? ( +
+ {dims ? ( +

+ Tamaño original: {dims.w} × {dims.h}px + {video ? ' (vídeo)' : slides.length > 1 ? ` · slide ${idx + 1}` : ''} +

+ ) : null} +
+ ); } 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" > -
- {p.mediaUrl && isVideoPost(p) ? ( -
+

{fmtDate(p.createdAt)} + {p.mediaProductType + ? ` · ${p.mediaProductType}` + : ''}

{p.message ? (

@@ -575,6 +686,11 @@ export default function SocialDashboard({ {n(p.reach)} alc. )} + {p.impressions != null && ( + + {n(p.impressions)} views + + )}

{p.permalink && (